Coverage Report

Created: 2022-08-31 14:14

/Users/oliverbrotchie/Documents/GitHub/csv_ledger/src/parse.rs
Line
Count
Source
1
//! # Zero-coppy CSV Parsing
2
//!  Validate headers and parse transactions from text.
3
//!
4
//! **Basic example:**
5
//! ```rust
6
//! use crate::parse::{parse_header, parse_transaction}
7
//!
8
//! fn main() {
9
//!     // Example csv data
10
//!     let csv = "type, client, tx, amount,
11
//!         deposit, 1, 1, 17.99
12
//!         withdrawal, 2, 2, 12.00
13
//!         hold 1, 1, ";
14
//!
15
//!     let mut lines = csv.split("\n");
16
//!     let mut transactions = Vec::new();
17
//!
18
//!     // Validate that the header is in the correct format
19
//!     parse_header(lines.next().unwrap()).expect("Header was invalid.");
20
//!
21
//!     // Insert all transactions into a vector
22
//!     for line in lines {
23
//!         transactions.push(parse_transaction(line).expect("Transaction was invalid."));
24
//!     }
25
//!
26
//!     // Print out the vector
27
//!     println!("{:?}", transactions);
28
//! }
29
//! ```
30
31
extern crate nom;
32
33
use nom::{
34
    branch::alt,
35
    bytes::complete::{tag, take_while, take_while_m_n},
36
    character::{
37
        complete::{multispace0, u16, u32},
38
        is_digit,
39
    },
40
    error::{Error as SubErr, ErrorKind, ParseError},
41
    sequence::{delimited, terminated},
42
    Err as NomErr, IResult,
43
};
44
45
/// An enum that represents possible transaction types.
46
13
#[derive(
D5
ebu
g5
, PartialEq, Eq)]
Unexecuted instantiation: <csv_ledger::parse::Transaction as core::cmp::PartialEq>::ne
<csv_ledger::parse::Transaction as core::cmp::PartialEq>::eq
Line
Count
Source
46
13
#[derive(Debug, PartialEq, Eq)]
47
pub enum Transaction {
48
    Deposit(u16, u32, i64),
49
    Withdrawal(u16, u32, i64),
50
    Dispute(u16, u32),
51
    Resolve(u16, u32),
52
    Chargeback(u16, u32),
53
}
54
55
/// A helper function to construct nom errors from custom strings.
56
22
pub fn nom_err(input: &str) -> NomErr<SubErr<&str>> {
57
22
    NomErr::Failure(SubErr {
58
22
        input,
59
22
        code: ErrorKind::Fail,
60
22
    })
61
22
}
62
63
/// A parser that ignores whitespace around the input parser.
64
165
fn ws<'a, F: 'a, O, E: ParseError<&'a str>>(
65
165
    inner: F,
66
165
) -> impl FnMut(&'a str) -> IResult<&'a str, O, E>
67
165
where
68
165
    F: FnMut(&'a str) -> IResult<&'a str, O, E>,
69
165
{
70
165
    delimited(multispace0, inner, multispace0)
71
165
}
csv_ledger::parse::ws::<nom::bytes::complete::tag<&str, &str, (&str, nom::error::ErrorKind)>::{closure#0}, &str, (&str, nom::error::ErrorKind)>
Line
Count
Source
64
1
fn ws<'a, F: 'a, O, E: ParseError<&'a str>>(
65
1
    inner: F,
66
1
) -> impl FnMut(&'a str) -> IResult<&'a str, O, E>
67
1
where
68
1
    F: FnMut(&'a str) -> IResult<&'a str, O, E>,
69
1
{
70
1
    delimited(multispace0, inner, multispace0)
71
1
}
csv_ledger::parse::ws::<nom::character::complete::u16<&str, nom::error::Error<&str>>, u16, nom::error::Error<&str>>
Line
Count
Source
64
31
fn ws<'a, F: 'a, O, E: ParseError<&'a str>>(
65
31
    inner: F,
66
31
) -> impl FnMut(&'a str) -> IResult<&'a str, O, E>
67
31
where
68
31
    F: FnMut(&'a str) -> IResult<&'a str, O, E>,
69
31
{
70
31
    delimited(multispace0, inner, multispace0)
71
31
}
csv_ledger::parse::ws::<nom::bytes::complete::tag<&str, &str, nom::error::Error<&str>>::{closure#0}, &str, nom::error::Error<&str>>
Line
Count
Source
64
70
fn ws<'a, F: 'a, O, E: ParseError<&'a str>>(
65
70
    inner: F,
66
70
) -> impl FnMut(&'a str) -> IResult<&'a str, O, E>
67
70
where
68
70
    F: FnMut(&'a str) -> IResult<&'a str, O, E>,
69
70
{
70
70
    delimited(multispace0, inner, multispace0)
71
70
}
csv_ledger::parse::ws::<nom::bytes::complete::tag<&str, &str, ()>::{closure#0}, &str, ()>
Line
Count
Source
64
1
fn ws<'a, F: 'a, O, E: ParseError<&'a str>>(
65
1
    inner: F,
66
1
) -> impl FnMut(&'a str) -> IResult<&'a str, O, E>
67
1
where
68
1
    F: FnMut(&'a str) -> IResult<&'a str, O, E>,
69
1
{
70
1
    delimited(multispace0, inner, multispace0)
71
1
}
csv_ledger::parse::ws::<nom::branch::alt<&str, &str, nom::error::Error<&str>, (nom::bytes::complete::tag<&str, &str, nom::error::Error<&str>>::{closure#0}, nom::bytes::complete::tag<&str, &str, nom::error::Error<&str>>::{closure#0}, nom::bytes::complete::tag<&str, &str, nom::error::Error<&str>>::{closure#0}, nom::bytes::complete::tag<&str, &str, nom::error::Error<&str>>::{closure#0}, nom::bytes::complete::tag<&str, &str, nom::error::Error<&str>>::{closure#0})>::{closure#0}, &str, nom::error::Error<&str>>
Line
Count
Source
64
33
fn ws<'a, F: 'a, O, E: ParseError<&'a str>>(
65
33
    inner: F,
66
33
) -> impl FnMut(&'a str) -> IResult<&'a str, O, E>
67
33
where
68
33
    F: FnMut(&'a str) -> IResult<&'a str, O, E>,
69
33
{
70
33
    delimited(multispace0, inner, multispace0)
71
33
}
csv_ledger::parse::ws::<nom::character::complete::u32<&str, nom::error::Error<&str>>, u32, nom::error::Error<&str>>
Line
Count
Source
64
29
fn ws<'a, F: 'a, O, E: ParseError<&'a str>>(
65
29
    inner: F,
66
29
) -> impl FnMut(&'a str) -> IResult<&'a str, O, E>
67
29
where
68
29
    F: FnMut(&'a str) -> IResult<&'a str, O, E>,
69
29
{
70
29
    delimited(multispace0, inner, multispace0)
71
29
}
72
73
/// Test if a character is a digit.
74
104
pub fn digit(chr: char) -> bool {
75
104
    chr.is_ascii() && is_digit(chr as u8)
76
104
}
77
78
/// Parse a i64 number from a string, optionally allowing a maximum number of digits to be specified.
79
49
pub fn double(input: &str, max: Option<usize>) -> IResult<&str, i64> {
80
49
    let (
input, num48
) = match max {
81
17
        Some(m) => take_while_m_n(1, m, digit)(input),
82
32
        None => take_while(digit)(input),
83
1
    }?;
84
85
    // Convert the string to i64
86
    Ok((
87
48
        input,
88
48
        num.parse::<i64>()
89
48
            .map_err(|_| 
nom_err("Could not parse number as i64.")14
)
?14
,
90
    ))
91
49
}
92
93
#[inline]
94
/// Parse an up to four decimal place number as an i64 by multiplying by 10000.
95
32
pub fn four_dp(input: &str) -> IResult<&str, i64> {
96
32
    let (
input, pre_dp18
) = double(input, None)
?14
;
97
98
    // Optionally parse decimal places
99
18
    if let Ok((
input17
, _)) = tag::<_, _, (&str, ErrorKind)>(".")(input) {
100
17
        let (
input, post_dp16
) = double(input, Some(4))
?1
;
101
102
        // Convert decimal places to whole numbers
103
16
        return Ok((
104
16
            input,
105
16
            (pre_dp * 10000 + post_dp * 10_i64.pow(3 - post_dp.checked_ilog10().unwrap_or(0))),
106
16
        ));
107
1
    }
108
1
109
1
    Ok((input, (pre_dp * 10000)))
110
32
}
111
112
/// Parse a line of the CSV as a Transaction.
113
/// Please note that whitespace will be ignored.
114
///
115
/// Example:
116
/// ```ts
117
/// // Valid Inputs:
118
/// assert_eq!(parse_transaction("deposit, 1, 1, 20.0"), ("", Transaction::Deposit(1, 1, 200)))
119
/// assert_eq!(parse_transaction(" deposit,  2, 20  ,6.99  "), ("", Transaction::Deposit(2, 20, 699)));
120
/// assert_eq!(parse_transaction("withdrawal, 3, 7, 22.7"), ("", Transaction::Withdrawal(3, 7, 2270)));
121
///
122
/// assert_eq!(parse_transaction("dispute, 2, 2,"), ("", Transaction::Dispute(2, 2)));
123
/// assert_eq!(parse_transaction("resolve, 2, 2,"), ("", Transaction::Resolve(2, 2)));
124
///
125
/// assert_eq!(parse_transaction("dispute, 3, 7,"), ("", Transaction::Dispute(3, 7)));
126
/// assert_eq!(parse_transaction("chargeback, 3, 7,"), ("", Transaction::Chargeback(3, 7)));
127
///
128
/// // Invalid Inputs:
129
/// assert!(parse_transaction("deposit, 1, 1,").is_err());
130
/// assert!(parse_transaction("xyz, 1, 1, 2.0").is_err());
131
/// assert!(parse_transaction("dispute, 1,").is_err());
132
/// ```
133
#[inline]
134
33
pub fn parse_transaction(input: &str) -> Result<Transaction, NomErr<SubErr<&str>>> {
135
    // Parse the type of Transaction
136
33
    let (
input, key31
) = terminated(
137
33
        ws(alt((
138
33
            tag("deposit"),
139
33
            tag("withdrawal"),
140
33
            tag("dispute"),
141
33
            tag("resolve"),
142
33
            tag("chargeback"),
143
33
        ))),
144
33
        tag(","),
145
33
    )(input)
?2
;
146
147
    // Parse the account and Transaction ID
148
31
    let (
input, client29
) = terminated(ws(u16), tag(","))(input)
?2
;
149
29
    let (
input, tx27
) = terminated(ws(u32), tag(","))(input)
?2
;
150
151
    // Parse the Transaction amount
152
27
    let amount = delimited(multispace0, four_dp, multispace0)(input).ok();
153
154
    // Check that the line has been consumed completely
155
27
    if let Some((
input14
,_)) = amount &&
!input.is_empty()14
{
156
1
        Err(nom_err("Input was not empty after parsing transaction."))?;
157
26
    }
158
159
    // Convert result into Transaction
160
26
    Ok(match (key, amount) {
161
26
        ("deposit", Some((_, 
value))) => Transaction::Deposit(client, tx, value)10
,
162
16
        ("withdrawal", Some((_, 
value))) => Transaction::Withdrawal(client, tx, value)2
,
163
14
        ("dispute", None) => 
Transaction::Dispute(client, tx)4
,
164
10
        ("resolve", None) => 
Transaction::Resolve(client, tx)2
,
165
8
        ("chargeback", None) => 
Transaction::Chargeback(client, tx)2
,
166
6
        (_, _) => Err(nom_err(if key == "deposit" || 
key == "withdrawal"2
{
167
5
            "Deposit or Withdrawal with a missing or invalid amount."
168
        } else {
169
1
            "Dispute, Resolve or Chargeback with an amount."
170
6
        }))?,
171
    })
172
33
}
173
174
/// Parse the CSV header to validate that the CSV is in the correct format.
175
/// Please note that whitespace will be ignored.
176
///
177
/// Example:
178
/// ```rs
179
/// assert!(parse_header("type, client, tx, amount").is_ok());
180
/// assert!(parse_header(" type,  client, tx  ,amount  ").is_ok());
181
///
182
/// assert!(parse_header("type, client, tx").is_err());
183
/// ```
184
#[inline]
185
24
pub fn parse_header(input: &str) -> Result<(), NomErr<SubErr<&str>>> {
186
24
    let (
input17
, _) = terminated(ws(tag("type")), tag(","))(input)
?7
;
187
17
    let (
input15
, _) = terminated(ws(tag("client")), tag(","))(input)
?2
;
188
15
    let (
input14
, _) = terminated(ws(tag("tx")), tag(","))(input)
?1
;
189
14
    let (
input12
, _) = ws(tag("amount"))(input)
?2
;
190
191
12
    if !input.is_empty() {
192
1
        return Err(nom_err("Input was not empty after parsing transaction."));
193
11
    }
194
11
195
11
    Ok(())
196
24
}
197
198
#[cfg(test)]
199
mod parse_transaction {
200
    use crate::parse::{parse_transaction, Transaction};
201
202
1
    #[test]
203
1
    fn deposit() {
204
1
        let res = parse_transaction("deposit, 1, 2, 3.1").unwrap();
205
1
        assert_eq!(res, Transaction::Deposit(1, 2, 31000));
206
1
    }
207
208
1
    #[test]
209
1
    fn withdrawal() {
210
1
        let res = parse_transaction("withdrawal, 1, 2, 3.0").unwrap();
211
1
        assert_eq!(res, Transaction::Withdrawal(1, 2, 30000));
212
1
    }
213
214
1
    #[test]
215
1
    fn dispute() {
216
1
        let res = parse_transaction("dispute, 1, 2,").unwrap();
217
1
        assert_eq!(res, Transaction::Dispute(1, 2));
218
1
    }
219
220
1
    #[test]
221
1
    fn resolve() {
222
1
        let res = parse_transaction("resolve, 1, 2,").unwrap();
223
1
        assert_eq!(res, Transaction::Resolve(1, 2));
224
1
    }
225
226
1
    #[test]
227
1
    fn chargeback() {
228
1
        let res = parse_transaction("chargeback, 1, 2,").unwrap();
229
1
        assert_eq!(res, Transaction::Chargeback(1, 2));
230
1
    }
231
232
1
    #[test]
233
1
    fn ok_no_white_space() {
234
1
        let res = parse_transaction("deposit,1,2,3.0").unwrap();
235
1
236
1
        assert_eq!(res, Transaction::Deposit(1, 2, 30000));
237
1
    }
238
239
1
    #[test]
240
1
    fn ok_with_white_space() {
241
1
        let res = parse_transaction("       deposit   ,1  ,   2,  3.0  ").unwrap();
242
1
        assert_eq!(res, Transaction::Deposit(1, 2, 30000));
243
1
    }
244
245
1
    #[test]
246
1
    fn ok_no_amount() {
247
1
        let res = parse_transaction("dispute,1,2,").unwrap();
248
1
        assert_eq!(res, Transaction::Dispute(1, 2));
249
1
    }
250
251
1
    #[test]
252
1
    fn err_parser_runthrough() {
253
1
        parse_transaction("x").unwrap_err();
254
1
        parse_transaction("deposit,x").unwrap_err();
255
1
        parse_transaction("deposit,1,x").unwrap_err();
256
1
        parse_transaction("deposit,1,2,x").unwrap_err();
257
1
        parse_transaction(&format!("deposit,1,2,2{}", f32::MAX)).unwrap_err();
258
1
    }
259
260
1
    #[test]
261
1
    fn err_invalid_u16() {
262
1
        parse_transaction("deposit,65536,2,3.0").unwrap_err();
263
1
    }
264
265
1
    #[test]
266
1
    fn err_invalid_deposit() {
267
1
        parse_transaction("deposit,1,2,").unwrap_err();
268
1
    }
269
270
1
    #[test]
271
1
    fn err_dispute_missing_value() {
272
1
        parse_transaction("dispute,1,").unwrap_err();
273
1
    }
274
275
1
    #[test]
276
1
    fn err_withdrawal_missing_value() {
277
1
        let res = parse_transaction("withdrawal,1,2,").unwrap_err();
278
1
        assert_eq!(
279
1
            res.to_string(),
280
1
            "Parsing Failure: Error { input: \"Deposit or Withdrawal with a missing or invalid amount.\", code: Fail }"
281
1
        );
282
1
    }
283
284
1
    #[test]
285
1
    fn err_deposit_missing_value() {
286
1
        let res = parse_transaction("deposit,1,2,").unwrap_err();
287
1
        assert_eq!(
288
1
            res.to_string(),
289
1
            "Parsing Failure: Error { input: \"Deposit or Withdrawal with a missing or invalid amount.\", code: Fail }"
290
1
        );
291
1
    }
292
293
1
    #[test]
294
1
    fn err_dispute_extra_value() {
295
1
        let res = parse_transaction("dispute,1,2,3.0").unwrap_err();
296
1
297
1
        assert_eq!(
298
1
            res.to_string(),
299
1
            "Parsing Failure: Error { input: \"Dispute, Resolve or Chargeback with an amount.\", code: Fail }"
300
1
        );
301
1
    }
302
303
1
    #[test]
304
1
    fn err_extra_value() {
305
1
        parse_transaction("withdrawal,1,2,3.0,foo").unwrap_err();
306
1
    }
307
}
308
309
#[cfg(test)]
310
mod four_dp {
311
1
    #[test]
312
1
    fn ok() {
313
1
        let value = super::four_dp("1").unwrap().1;
314
1
        assert_eq!(value, 10000);
315
1
    }
316
317
1
    #[test]
318
1
    fn ok_one_sig_fig() {
319
1
        let value = super::four_dp("1.1").unwrap().1;
320
1
        assert_eq!(value, 11000);
321
1
    }
322
323
1
    #[test]
324
1
    fn ok_four_sig_fig() {
325
1
        let value = super::four_dp("1.1111").unwrap().1;
326
1
        assert_eq!(value, 11111);
327
1
    }
328
329
1
    #[test]
330
1
    fn err_runthrough() {
331
1
        super::four_dp("").unwrap_err();
332
1
        super::four_dp("1.").unwrap_err();
333
1
    }
334
}
335
336
#[cfg(test)]
337
mod transaction {
338
339
1
    #[test]
340
1
    fn debug() {
341
1
        assert_eq!(
342
1
            format!("{:?}", super::Transaction::Deposit(1, 1, 2)),
343
1
            "Deposit(1, 1, 2)"
344
1
        );
345
1
        assert_eq!(
346
1
            format!("{:?}", super::Transaction::Withdrawal(1, 1, 2)),
347
1
            "Withdrawal(1, 1, 2)"
348
1
        );
349
1
        assert_eq!(
350
1
            format!("{:?}", super::Transaction::Dispute(1, 1)),
351
1
            "Dispute(1, 1)"
352
1
        );
353
1
        assert_eq!(
354
1
            format!("{:?}", super::Transaction::Resolve(1, 1)),
355
1
            "Resolve(1, 1)"
356
1
        );
357
1
        assert_eq!(
358
1
            format!("{:?}", super::Transaction::Chargeback(1, 1)),
359
1
            "Chargeback(1, 1)"
360
1
        );
361
1
    }
362
363
1
    #[test]
364
1
    fn partial_eq() {
365
1
        assert_eq!(
366
1
            super::Transaction::Deposit(1, 1, 20),
367
1
            super::Transaction::Deposit(1, 1, 20)
368
1
        );
369
1
        assert_eq!(
370
1
            super::Transaction::Withdrawal(1, 1, 20),
371
1
            super::Transaction::Withdrawal(1, 1, 20)
372
1
        );
373
1
        assert_eq!(
374
1
            super::Transaction::Dispute(1, 1),
375
1
            super::Transaction::Dispute(1, 1)
376
1
        );
377
1
        assert_eq!(
378
1
            super::Transaction::Resolve(1, 1),
379
1
            super::Transaction::Resolve(1, 1)
380
1
        );
381
1
        assert_eq!(
382
1
            super::Transaction::Chargeback(1, 1),
383
1
            super::Transaction::Chargeback(1, 1)
384
1
        );
385
1
    }
386
}
387
388
#[cfg(test)]
389
mod ws {
390
    use super::*;
391
    use nom::bytes::complete::tag;
392
393
1
    #[test]
394
1
    fn ok_ws() {
395
1
        let (input, tag) = ws(tag::<_, _, ()>("hello"))("  hello  ").unwrap();
396
1
397
1
        assert_eq!(input, "");
398
1
        assert_eq!(tag, "hello");
399
1
    }
400
401
1
    #[test]
402
1
    fn invalid_inner<'a>() {
403
1
        ws(tag("hello"))("").unwrap_err() as nom::Err<(&'a str, nom::error::ErrorKind)>;
404
1
    }
405
}
406
407
#[cfg(test)]
408
mod parse_header {
409
    use crate::parse::parse_header;
410
411
1
    #[test]
412
1
    fn ok_no_white_space() {
413
1
        parse_header("type,client,tx,amount").expect("Error whilst parsing header.");
414
1
    }
415
416
1
    #[test]
417
1
    fn ok_with_white_space() {
418
1
        parse_header("   type    ,  client,   tx  ,    amount    ")
419
1
            .expect("Error whilst parsing header.");
420
1
    }
421
422
1
    #[test]
423
1
    fn err_invalid_input() {
424
1
        parse_header("client,type,ammount,tx").unwrap_err();
425
1
    }
426
427
1
    #[test]
428
1
    fn err_missing_value() {
429
1
        parse_header("type,client,tx,").unwrap_err();
430
1
    }
431
432
1
    #[test]
433
1
    fn err_parser_runthrough() {
434
1
        parse_header("x").unwrap_err();
435
1
        parse_header("type,x").unwrap_err();
436
1
        parse_header("type,client,x").unwrap_err();
437
1
        parse_header("type,client,tx,x").unwrap_err();
438
1
    }
439
440
1
    #[test]
441
1
    fn err_extra_value() {
442
1
        parse_header("type,client,tx,amount,foo").unwrap_err();
443
1
    }
444
}