Coverage Report

Created: 2022-09-02 18:59

/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
14
#[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
14
#[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
168
fn ws<'a, F: 'a, O, E: ParseError<&'a str>>(
65
168
    inner: F,
66
168
) -> impl FnMut(&'a str) -> IResult<&'a str, O, E>
67
168
where
68
168
    F: FnMut(&'a str) -> IResult<&'a str, O, E>,
69
168
{
70
168
    delimited(multispace0, inner, multispace0)
71
168
}
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::character::complete::u32<&str, nom::error::Error<&str>>, u32, nom::error::Error<&str>>
Line
Count
Source
64
30
fn ws<'a, F: 'a, O, E: ParseError<&'a str>>(
65
30
    inner: F,
66
30
) -> impl FnMut(&'a str) -> IResult<&'a str, O, E>
67
30
where
68
30
    F: FnMut(&'a str) -> IResult<&'a str, O, E>,
69
30
{
70
30
    delimited(multispace0, inner, multispace0)
71
30
}
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
34
fn ws<'a, F: 'a, O, E: ParseError<&'a str>>(
65
34
    inner: F,
66
34
) -> impl FnMut(&'a str) -> IResult<&'a str, O, E>
67
34
where
68
34
    F: FnMut(&'a str) -> IResult<&'a str, O, E>,
69
34
{
70
34
    delimited(multispace0, inner, multispace0)
71
34
}
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, (&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
32
fn ws<'a, F: 'a, O, E: ParseError<&'a str>>(
65
32
    inner: F,
66
32
) -> impl FnMut(&'a str) -> IResult<&'a str, O, E>
67
32
where
68
32
    F: FnMut(&'a str) -> IResult<&'a str, O, E>,
69
32
{
70
32
    delimited(multispace0, inner, multispace0)
71
32
}
72
73
/// Test if a character is a digit.
74
105
pub fn digit(chr: char) -> bool {
75
105
    chr.is_ascii() && is_digit(chr as u8)
76
105
}
77
78
/// Parse a i64 number from a string, optionally allowing a maximum number of digits to be specified.
79
50
pub fn double(input: &str, max: Option<usize>) -> IResult<&str, i64> {
80
50
    let (
input, num49
) = match max {
81
17
        Some(m) => take_while_m_n(1, m, digit)(input),
82
33
        None => take_while(digit)(input),
83
1
    }?;
84
85
    // Convert the string to i64
86
    Ok((
87
49
        input,
88
49
        num.parse::<i64>()
89
49
            .map_err(|_| 
nom_err("Could not parse number as i64.")14
)
?14
,
90
    ))
91
50
}
92
93
#[inline]
94
/// Parse an up to four decimal place number as an i64 by multiplying by 10000.
95
33
pub fn four_dp(input: &str) -> IResult<&str, i64> {
96
33
    let (
input, pre_dp19
) = double(input, None)
?14
;
97
98
    // Optionally parse decimal places
99
19
    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 as f32).log10() as u32)),
106
16
        ));
107
2
    }
108
2
109
2
    Ok((input, (pre_dp * 10000)))
110
33
}
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
34
pub fn parse_transaction(input: &str) -> Result<Transaction, NomErr<SubErr<&str>>> {
135
    // Parse the type of Transaction
136
34
    let (
input, key32
) = terminated(
137
34
        ws(alt((
138
34
            tag("deposit"),
139
34
            tag("withdrawal"),
140
34
            tag("dispute"),
141
34
            tag("resolve"),
142
34
            tag("chargeback"),
143
34
        ))),
144
34
        tag(","),
145
34
    )(input)
?2
;
146
147
    // Parse the account and Transaction ID
148
32
    let (
input, client30
) = terminated(ws(u16), tag(","))(input)
?2
;
149
30
    let (
input, tx28
) = terminated(ws(u32), tag(","))(input)
?2
;
150
151
    // Parse the Transaction amount
152
28
    let amount = delimited(multispace0, four_dp, multispace0)(input).ok();
153
154
    // Check that the line has been consumed completely
155
28
    if let Some((
input15
, _)) = amount {
156
15
        if !input.is_empty() {
157
1
            Err(nom_err("Input was not empty after parsing transaction."))?;
158
14
        }
159
13
    }
160
161
    // Convert result into Transaction
162
27
    Ok(match (key, amount) {
163
27
        ("deposit", Some((_, 
value))) => Transaction::Deposit(client, tx, value)11
,
164
16
        ("withdrawal", Some((_, 
value))) => Transaction::Withdrawal(client, tx, value)2
,
165
14
        ("dispute", None) => 
Transaction::Dispute(client, tx)4
,
166
10
        ("resolve", None) => 
Transaction::Resolve(client, tx)2
,
167
8
        ("chargeback", None) => 
Transaction::Chargeback(client, tx)2
,
168
6
        (_, _) => Err(nom_err(if key == "deposit" || 
key == "withdrawal"2
{
169
5
            "Deposit or Withdrawal with a missing or invalid amount."
170
        } else {
171
1
            "Dispute, Resolve or Chargeback with an amount."
172
6
        }))?,
173
    })
174
34
}
175
176
/// Parse the CSV header to validate that the CSV is in the correct format.
177
/// Please note that whitespace will be ignored.
178
///
179
/// Example:
180
/// ```rs
181
/// assert!(parse_header("type, client, tx, amount").is_ok());
182
/// assert!(parse_header(" type,  client, tx  ,amount  ").is_ok());
183
///
184
/// assert!(parse_header("type, client, tx").is_err());
185
/// ```
186
#[inline]
187
24
pub fn parse_header(input: &str) -> Result<(), NomErr<SubErr<&str>>> {
188
24
    let (
input17
, _) = terminated(ws(tag("type")), tag(","))(input)
?7
;
189
17
    let (
input15
, _) = terminated(ws(tag("client")), tag(","))(input)
?2
;
190
15
    let (
input14
, _) = terminated(ws(tag("tx")), tag(","))(input)
?1
;
191
14
    let (
input12
, _) = ws(tag("amount"))(input)
?2
;
192
193
12
    if !input.is_empty() {
194
1
        return Err(nom_err("Input was not empty after parsing transaction."));
195
11
    }
196
11
197
11
    Ok(())
198
24
}
199
200
#[cfg(test)]
201
mod parse_transaction {
202
    use crate::parse::{parse_transaction, Transaction};
203
204
1
    #[test]
205
1
    fn deposit() {
206
1
        let res = parse_transaction("deposit, 1, 2, 3.1").unwrap();
207
1
        assert_eq!(res, Transaction::Deposit(1, 2, 31000));
208
1
    }
209
210
1
    #[test]
211
1
    fn withdrawal() {
212
1
        let res = parse_transaction("withdrawal, 1, 2, 3.0").unwrap();
213
1
        assert_eq!(res, Transaction::Withdrawal(1, 2, 30000));
214
1
    }
215
216
1
    #[test]
217
1
    fn dispute() {
218
1
        let res = parse_transaction("dispute, 1, 2,").unwrap();
219
1
        assert_eq!(res, Transaction::Dispute(1, 2));
220
1
    }
221
222
1
    #[test]
223
1
    fn resolve() {
224
1
        let res = parse_transaction("resolve, 1, 2,").unwrap();
225
1
        assert_eq!(res, Transaction::Resolve(1, 2));
226
1
    }
227
228
1
    #[test]
229
1
    fn chargeback() {
230
1
        let res = parse_transaction("chargeback, 1, 2,").unwrap();
231
1
        assert_eq!(res, Transaction::Chargeback(1, 2));
232
1
    }
233
234
1
    #[test]
235
1
    fn ok_no_decimal() {
236
1
        let res = parse_transaction("deposit, 1, 2, 3").unwrap();
237
1
        assert_eq!(res, Transaction::Deposit(1, 2, 30000));
238
1
    }
239
240
1
    #[test]
241
1
    fn ok_no_white_space() {
242
1
        let res = parse_transaction("deposit,1,2,3.0").unwrap();
243
1
244
1
        assert_eq!(res, Transaction::Deposit(1, 2, 30000));
245
1
    }
246
247
1
    #[test]
248
1
    fn ok_with_white_space() {
249
1
        let res = parse_transaction("       deposit   ,1  ,   2,  3.0  ").unwrap();
250
1
        assert_eq!(res, Transaction::Deposit(1, 2, 30000));
251
1
    }
252
253
1
    #[test]
254
1
    fn ok_no_amount() {
255
1
        let res = parse_transaction("dispute,1,2,").unwrap();
256
1
        assert_eq!(res, Transaction::Dispute(1, 2));
257
1
    }
258
259
1
    #[test]
260
1
    fn err_parser_runthrough() {
261
1
        parse_transaction("x").unwrap_err();
262
1
        parse_transaction("deposit,x").unwrap_err();
263
1
        parse_transaction("deposit,1,x").unwrap_err();
264
1
        parse_transaction("deposit,1,2,x").unwrap_err();
265
1
        parse_transaction(&format!("deposit,1,2,2{}", f32::MAX)).unwrap_err();
266
1
    }
267
268
1
    #[test]
269
1
    fn err_invalid_u16() {
270
1
        parse_transaction("deposit,65536,2,3.0").unwrap_err();
271
1
    }
272
273
1
    #[test]
274
1
    fn err_invalid_deposit() {
275
1
        parse_transaction("deposit,1,2,").unwrap_err();
276
1
    }
277
278
1
    #[test]
279
1
    fn err_dispute_missing_value() {
280
1
        parse_transaction("dispute,1,").unwrap_err();
281
1
    }
282
283
1
    #[test]
284
1
    fn err_withdrawal_missing_value() {
285
1
        let res = parse_transaction("withdrawal,1,2,").unwrap_err();
286
1
        assert_eq!(
287
1
            res.to_string(),
288
1
            "Parsing Failure: Error { input: \"Deposit or Withdrawal with a missing or invalid amount.\", code: Fail }"
289
1
        );
290
1
    }
291
292
1
    #[test]
293
1
    fn err_deposit_missing_value() {
294
1
        let res = parse_transaction("deposit,1,2,").unwrap_err();
295
1
        assert_eq!(
296
1
            res.to_string(),
297
1
            "Parsing Failure: Error { input: \"Deposit or Withdrawal with a missing or invalid amount.\", code: Fail }"
298
1
        );
299
1
    }
300
301
1
    #[test]
302
1
    fn err_dispute_extra_value() {
303
1
        let res = parse_transaction("dispute,1,2,3.0").unwrap_err();
304
1
305
1
        assert_eq!(
306
1
            res.to_string(),
307
1
            "Parsing Failure: Error { input: \"Dispute, Resolve or Chargeback with an amount.\", code: Fail }"
308
1
        );
309
1
    }
310
311
1
    #[test]
312
1
    fn err_extra_value() {
313
1
        parse_transaction("withdrawal,1,2,3.0,foo").unwrap_err();
314
1
    }
315
}
316
317
#[cfg(test)]
318
mod four_dp {
319
1
    #[test]
320
1
    fn ok() {
321
1
        let value = super::four_dp("1").unwrap().1;
322
1
        assert_eq!(value, 10000);
323
1
    }
324
325
1
    #[test]
326
1
    fn ok_one_sig_fig() {
327
1
        let value = super::four_dp("1.1").unwrap().1;
328
1
        assert_eq!(value, 11000);
329
1
    }
330
331
1
    #[test]
332
1
    fn ok_four_sig_fig() {
333
1
        let value = super::four_dp("1.1111").unwrap().1;
334
1
        assert_eq!(value, 11111);
335
1
    }
336
337
1
    #[test]
338
1
    fn err_runthrough() {
339
1
        super::four_dp("").unwrap_err();
340
1
        super::four_dp("1.").unwrap_err();
341
1
    }
342
}
343
344
#[cfg(test)]
345
mod transaction {
346
347
1
    #[test]
348
1
    fn debug() {
349
1
        assert_eq!(
350
1
            format!("{:?}", super::Transaction::Deposit(1, 1, 2)),
351
1
            "Deposit(1, 1, 2)"
352
1
        );
353
1
        assert_eq!(
354
1
            format!("{:?}", super::Transaction::Withdrawal(1, 1, 2)),
355
1
            "Withdrawal(1, 1, 2)"
356
1
        );
357
1
        assert_eq!(
358
1
            format!("{:?}", super::Transaction::Dispute(1, 1)),
359
1
            "Dispute(1, 1)"
360
1
        );
361
1
        assert_eq!(
362
1
            format!("{:?}", super::Transaction::Resolve(1, 1)),
363
1
            "Resolve(1, 1)"
364
1
        );
365
1
        assert_eq!(
366
1
            format!("{:?}", super::Transaction::Chargeback(1, 1)),
367
1
            "Chargeback(1, 1)"
368
1
        );
369
1
    }
370
371
1
    #[test]
372
1
    fn partial_eq() {
373
1
        assert_eq!(
374
1
            super::Transaction::Deposit(1, 1, 20),
375
1
            super::Transaction::Deposit(1, 1, 20)
376
1
        );
377
1
        assert_eq!(
378
1
            super::Transaction::Withdrawal(1, 1, 20),
379
1
            super::Transaction::Withdrawal(1, 1, 20)
380
1
        );
381
1
        assert_eq!(
382
1
            super::Transaction::Dispute(1, 1),
383
1
            super::Transaction::Dispute(1, 1)
384
1
        );
385
1
        assert_eq!(
386
1
            super::Transaction::Resolve(1, 1),
387
1
            super::Transaction::Resolve(1, 1)
388
1
        );
389
1
        assert_eq!(
390
1
            super::Transaction::Chargeback(1, 1),
391
1
            super::Transaction::Chargeback(1, 1)
392
1
        );
393
1
    }
394
}
395
396
#[cfg(test)]
397
mod ws {
398
    use super::*;
399
    use nom::bytes::complete::tag;
400
401
1
    #[test]
402
1
    fn ok_ws() {
403
1
        let (input, tag) = ws(tag::<_, _, ()>("hello"))("  hello  ").unwrap();
404
1
405
1
        assert_eq!(input, "");
406
1
        assert_eq!(tag, "hello");
407
1
    }
408
409
1
    #[test]
410
1
    fn invalid_inner<'a>() {
411
1
        ws(tag("hello"))("").unwrap_err() as nom::Err<(&'a str, nom::error::ErrorKind)>;
412
1
    }
413
}
414
415
#[cfg(test)]
416
mod parse_header {
417
    use crate::parse::parse_header;
418
419
1
    #[test]
420
1
    fn ok_no_white_space() {
421
1
        parse_header("type,client,tx,amount").expect("Error whilst parsing header.");
422
1
    }
423
424
1
    #[test]
425
1
    fn ok_with_white_space() {
426
1
        parse_header("   type    ,  client,   tx  ,    amount    ")
427
1
            .expect("Error whilst parsing header.");
428
1
    }
429
430
1
    #[test]
431
1
    fn err_invalid_input() {
432
1
        parse_header("client,type,ammount,tx").unwrap_err();
433
1
    }
434
435
1
    #[test]
436
1
    fn err_missing_value() {
437
1
        parse_header("type,client,tx,").unwrap_err();
438
1
    }
439
440
1
    #[test]
441
1
    fn err_parser_runthrough() {
442
1
        parse_header("x").unwrap_err();
443
1
        parse_header("type,x").unwrap_err();
444
1
        parse_header("type,client,x").unwrap_err();
445
1
        parse_header("type,client,tx,x").unwrap_err();
446
1
    }
447
448
1
    #[test]
449
1
    fn err_extra_value() {
450
1
        parse_header("type,client,tx,amount,foo").unwrap_err();
451
1
    }
452
}