beancount_parser/
transaction.rs

1use std::borrow::Borrow;
2use std::collections::HashSet;
3use std::fmt::{Display, Formatter};
4use std::sync::Arc;
5
6use nom::{
7    branch::alt,
8    bytes::complete::{tag, take_while},
9    character::complete::satisfy,
10    character::complete::{char as char_tag, space0, space1},
11    combinator::{cut, iterator, map, opt, success, value},
12    sequence::{delimited, preceded, separated_pair, terminated, tuple},
13    Parser,
14};
15
16use crate::string;
17use crate::{
18    account, account::Account, amount, amount::Amount, date, empty_line, end_of_line, metadata,
19    Date, Decimal, IResult, Span,
20};
21
22/// A transaction
23///
24/// It notably contains a list of [`Posting`]
25///
26/// # Example
27/// ```
28/// # use beancount_parser::{BeancountFile, DirectiveContent};
29/// let input = r#"
30/// 2022-05-22 * "Grocery store" "Grocery shopping" #food
31///   Assets:Cash           -10 CHF
32///   Expenses:Groceries
33/// "#;
34///
35/// let beancount: BeancountFile<f64> = input.parse().unwrap();
36/// let DirectiveContent::Transaction(trx) = &beancount.directives[0].content else {
37///   unreachable!("was not a transaction")
38/// };
39/// assert_eq!(trx.flag, Some('*'));
40/// assert_eq!(trx.payee.as_deref(), Some("Grocery store"));
41/// assert_eq!(trx.narration.as_deref(), Some("Grocery shopping"));
42/// assert!(trx.tags.contains("food"));
43/// assert_eq!(trx.postings.len(), 2);
44/// ```
45#[derive(Debug, Clone, PartialEq)]
46#[non_exhaustive]
47pub struct Transaction<D> {
48    /// Transaction flag (`*` or `!` or `None` when using the `txn` keyword)
49    pub flag: Option<char>,
50    /// Payee (if present)
51    pub payee: Option<String>,
52    /// Narration (if present)
53    pub narration: Option<String>,
54    /// Set of tags
55    pub tags: HashSet<Tag>,
56    /// Set of links
57    pub links: HashSet<Link>,
58    /// Postings
59    pub postings: Vec<Posting<D>>,
60}
61
62/// A transaction posting
63///
64/// # Example
65/// ```
66/// # use beancount_parser::{BeancountFile, DirectiveContent, PostingPrice};
67/// let input = r#"
68/// 2022-05-22 * "Grocery shopping"
69///   Assets:Cash           1 CHF {2 PLN} @ 3 EUR
70///   Expenses:Groceries
71/// "#;
72///
73/// let beancount: BeancountFile<f64> = input.parse().unwrap();
74/// let DirectiveContent::Transaction(trx) = &beancount.directives[0].content else {
75///   unreachable!("was not a transaction")
76/// };
77/// let posting = &trx.postings[0];
78/// assert_eq!(posting.account.as_str(), "Assets:Cash");
79/// assert_eq!(posting.amount.as_ref().unwrap().value, 1.0);
80/// assert_eq!(posting.amount.as_ref().unwrap().currency.as_str(), "CHF");
81/// assert_eq!(posting.cost.as_ref().unwrap().amount.as_ref().unwrap().value, 2.0);
82/// assert_eq!(posting.cost.as_ref().unwrap().amount.as_ref().unwrap().currency.as_str(), "PLN");
83/// let Some(PostingPrice::Unit(price)) = &posting.price else {
84///   unreachable!("no price");
85/// };
86/// assert_eq!(price.value, 3.0);
87/// assert_eq!(price.currency.as_str(), "EUR");
88/// ```
89#[derive(Debug, Clone, PartialEq)]
90#[non_exhaustive]
91pub struct Posting<D> {
92    /// Transaction flag (`*` or `!` or `None` when absent)
93    pub flag: Option<char>,
94    /// Account modified by the posting
95    pub account: Account,
96    /// Amount being added to the account
97    pub amount: Option<Amount<D>>,
98    /// Cost (content within `{` and `}`)
99    pub cost: Option<Cost<D>>,
100    /// Price (`@` or `@@`) syntax
101    pub price: Option<PostingPrice<D>>,
102    /// The metadata attached to the posting
103    pub metadata: metadata::Map<D>,
104}
105
106/// Cost of a posting
107///
108/// It is the amount within `{` and `}`.
109#[derive(Debug, Default, Clone, PartialEq)]
110#[non_exhaustive]
111pub struct Cost<D> {
112    /// Cost basis of the posting
113    pub amount: Option<Amount<D>>,
114    /// The date of this cost basis
115    pub date: Option<Date>,
116}
117
118/// Price of a posting
119///
120/// It is the amount following the `@` or `@@` symbols
121#[derive(Debug, Clone, PartialEq)]
122pub enum PostingPrice<D> {
123    /// Unit cost (`@`)
124    Unit(Amount<D>),
125    /// Total cost (`@@`)
126    Total(Amount<D>),
127}
128
129/// Transaction tag
130///
131/// # Example
132/// ```
133/// # use beancount_parser::{BeancountFile, DirectiveContent};
134/// let input = r#"
135/// 2022-05-22 * "Grocery store" "Grocery shopping" #food
136///   Assets:Cash           -10 CHF
137///   Expenses:Groceries
138/// "#;
139///
140/// let beancount: BeancountFile<f64> = input.parse().unwrap();
141/// let DirectiveContent::Transaction(trx) = &beancount.directives[0].content else {
142///   unreachable!("was not a transaction")
143/// };
144/// assert!(trx.tags.contains("food"));
145/// ```
146#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
147pub struct Tag(Arc<str>);
148
149impl Tag {
150    /// Returns underlying string representation
151    #[must_use]
152    pub fn as_str(&self) -> &str {
153        &self.0
154    }
155}
156
157impl Display for Tag {
158    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
159        Display::fmt(&self.0, f)
160    }
161}
162
163impl AsRef<str> for Tag {
164    fn as_ref(&self) -> &str {
165        self.0.as_ref()
166    }
167}
168
169impl Borrow<str> for Tag {
170    fn borrow(&self) -> &str {
171        self.0.borrow()
172    }
173}
174
175/// Transaction link
176///
177/// # Example
178/// ```
179/// # use beancount_parser::{BeancountFile, DirectiveContent};
180/// let input = r#"
181/// 2014-02-05 * "Invoice for January" ^invoice-pepe-studios-jan14
182///    Income:Clients:PepeStudios           -8450.00 USD
183///    Assets:AccountsReceivable
184/// "#;
185///
186/// let beancount: BeancountFile<f64> = input.parse().unwrap();
187/// let DirectiveContent::Transaction(trx) = &beancount.directives[0].content else {
188///   unreachable!("was not a transaction")
189/// };
190/// assert!(trx.links.contains("invoice-pepe-studios-jan14"));
191/// ```
192#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
193pub struct Link(Arc<str>);
194
195impl Link {
196    /// Returns underlying string representation
197    #[must_use]
198    pub fn as_str(&self) -> &str {
199        &self.0
200    }
201}
202
203impl Display for Link {
204    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
205        Display::fmt(&self.0, f)
206    }
207}
208
209impl AsRef<str> for Link {
210    fn as_ref(&self) -> &str {
211        self.0.as_ref()
212    }
213}
214
215impl Borrow<str> for Link {
216    fn borrow(&self) -> &str {
217        self.0.borrow()
218    }
219}
220
221#[allow(clippy::type_complexity)]
222pub(crate) fn parse<D: Decimal>(
223    input: Span<'_>,
224) -> IResult<'_, (Transaction<D>, metadata::Map<D>)> {
225    let (input, flag) = alt((map(flag, Some), value(None, tag("txn"))))(input)?;
226    cut(do_parse(flag))(input)
227}
228
229fn flag(input: Span<'_>) -> IResult<'_, char> {
230    satisfy(|c: char| !c.is_ascii_lowercase())(input)
231}
232
233fn do_parse<D: Decimal>(
234    flag: Option<char>,
235) -> impl Fn(Span<'_>) -> IResult<'_, (Transaction<D>, metadata::Map<D>)> {
236    move |input| {
237        let (input, payee_and_narration) = opt(preceded(space1, payee_and_narration))(input)?;
238        let (input, (tags, links)) = tags_and_links(input)?;
239        let (input, ()) = end_of_line(input)?;
240        let (input, metadata) = metadata::parse(input)?;
241        let mut iter = iterator(input, alt((posting.map(Some), empty_line.map(|()| None))));
242        let postings = iter.flatten().collect();
243        let (input, ()) = iter.finish()?;
244        let (payee, narration) = match payee_and_narration {
245            Some((payee, narration)) => (payee, Some(narration)),
246            None => (None, None),
247        };
248        Ok((
249            input,
250            (
251                Transaction {
252                    flag,
253                    payee,
254                    narration,
255                    tags,
256                    links,
257                    postings,
258                },
259                metadata,
260            ),
261        ))
262    }
263}
264
265pub(super) enum TagOrLink {
266    Tag(Tag),
267    Link(Link),
268}
269
270pub(super) fn parse_tag(input: Span<'_>) -> IResult<'_, Tag> {
271    map(
272        preceded(
273            char_tag('#'),
274            take_while(|c: char| c.is_alphanumeric() || c == '-' || c == '_'),
275        ),
276        |s: Span<'_>| Tag((*s.fragment()).into()),
277    )(input)
278}
279
280pub(super) fn parse_link(input: Span<'_>) -> IResult<'_, Link> {
281    map(
282        preceded(
283            char_tag('^'),
284            take_while(|c: char| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
285        ),
286        |s: Span<'_>| Link((*s.fragment()).into()),
287    )(input)
288}
289
290pub(super) fn parse_tag_or_link(input: Span<'_>) -> IResult<'_, TagOrLink> {
291    alt((
292        map(parse_tag, TagOrLink::Tag),
293        map(parse_link, TagOrLink::Link),
294    ))(input)
295}
296
297fn tags_and_links(input: Span<'_>) -> IResult<'_, (HashSet<Tag>, HashSet<Link>)> {
298    let mut tags_and_links_iter = iterator(input, preceded(space0, parse_tag_or_link));
299    let (tags, links) = tags_and_links_iter.fold(
300        (HashSet::new(), HashSet::new()),
301        |(mut tags, mut links), x| {
302            match x {
303                TagOrLink::Tag(tag) => tags.insert(tag),
304                TagOrLink::Link(link) => links.insert(link),
305            };
306            (tags, links)
307        },
308    );
309    let (input, ()) = tags_and_links_iter.finish()?;
310    Ok((input, (tags, links)))
311}
312
313fn payee_and_narration(input: Span<'_>) -> IResult<'_, (Option<String>, String)> {
314    let (input, s1) = string(input)?;
315    let (input, s2) = opt(preceded(space1, string))(input)?;
316    Ok((
317        input,
318        match s2 {
319            Some(narration) => (Some(s1), narration),
320            None => (None, s1),
321        },
322    ))
323}
324
325fn posting<D: Decimal>(input: Span<'_>) -> IResult<'_, Posting<D>> {
326    let (input, _) = space1(input)?;
327    let (input, flag) = opt(terminated(flag, space1))(input)?;
328    let (input, account) = account::parse(input)?;
329    let (input, amounts) = opt(tuple((
330        preceded(space1, amount::parse),
331        opt(preceded(space1, cost)),
332        opt(preceded(
333            space1,
334            alt((
335                map(
336                    preceded(tuple((char_tag('@'), space1)), amount::parse),
337                    PostingPrice::Unit,
338                ),
339                map(
340                    preceded(tuple((tag("@@"), space1)), amount::parse),
341                    PostingPrice::Total,
342                ),
343            )),
344        )),
345    )))(input)?;
346    let (input, ()) = end_of_line(input)?;
347    let (input, metadata) = metadata::parse(input)?;
348    let (amount, cost, price) = match amounts {
349        Some((a, l, p)) => (Some(a), l, p),
350        None => (None, None, None),
351    };
352    Ok((
353        input,
354        Posting {
355            flag,
356            account,
357            amount,
358            cost,
359            price,
360            metadata,
361        },
362    ))
363}
364
365fn cost<D: Decimal>(input: Span<'_>) -> IResult<'_, Cost<D>> {
366    let (input, _) = terminated(char_tag('{'), space0)(input)?;
367    let (input, (cost, date)) = alt((
368        map(
369            separated_pair(
370                amount::parse,
371                delimited(space0, char_tag(','), space0),
372                date::parse,
373            ),
374            |(a, d)| (Some(a), Some(d)),
375        ),
376        map(
377            separated_pair(
378                date::parse,
379                delimited(space0, char_tag(','), space0),
380                amount::parse,
381            ),
382            |(d, a)| (Some(a), Some(d)),
383        ),
384        map(amount::parse, |a| (Some(a), None)),
385        map(date::parse, |d| (None, Some(d))),
386        map(success(true), |_| (None, None)),
387    ))(input)?;
388    let (input, _) = preceded(space0, char_tag('}'))(input)?;
389    Ok((input, Cost { amount: cost, date }))
390}
391
392#[cfg(test)]
393mod chumsky {
394    use std::collections::HashSet;
395
396    use crate::{ChumskyParser, Decimal, Posting, PostingPrice, Transaction};
397    use chumsky::{prelude::*, text::whitespace};
398
399    use super::{Cost, Link, Tag, TagOrLink};
400
401    fn transaction<D: Decimal + 'static>() -> impl ChumskyParser<Transaction<D>> {
402        flag()
403            .then(payee_and_narration())
404            .then(tags_and_links())
405            .then(posting().padded().repeated())
406            .map(
407                |(((flag, (payee, narration)), (tags, links)), postings)| Transaction {
408                    flag,
409                    payee,
410                    narration,
411                    tags,
412                    links,
413                    postings,
414                },
415            )
416    }
417
418    fn flag() -> impl ChumskyParser<Option<char>> {
419        choice((
420            just("txn").to(None),
421            just('!').map(Some),
422            just('*').map(Some),
423        ))
424    }
425
426    fn payee_and_narration() -> impl ChumskyParser<(Option<String>, Option<String>)> {
427        whitespace()
428            .ignore_then(crate::chumksy::string())
429            .then(whitespace().ignore_then(crate::chumksy::string()).or_not())
430            .or_not()
431            .map(|v| match v {
432                Some((p, Some(n))) => (Some(p), Some(n)),
433                Some((n, None)) => (None, Some(n)),
434                None => (None, None),
435            })
436    }
437
438    fn tags_and_links() -> impl ChumskyParser<(HashSet<Tag>, HashSet<Link>)> {
439        let ident = filter(|c: &char| c.is_alphanumeric())
440            .or(one_of("_-."))
441            .repeated()
442            .at_least(1)
443            .collect::<String>();
444        let tag_or_link = whitespace().ignore_then(choice((
445            just('#')
446                .ignore_then(ident.clone().map(|s| super::Tag(s.into())))
447                .map(TagOrLink::Tag),
448            just('^')
449                .ignore_then(ident.map(|s| super::Link(s.into())))
450                .map(TagOrLink::Link),
451        )));
452        empty()
453            .map(|()| (HashSet::<Tag>::new(), HashSet::<Link>::new()))
454            .then(tag_or_link.padded().repeated())
455            .foldl(
456                |(mut tags, mut links): (HashSet<Tag>, HashSet<Link>), tag_or_link| {
457                    match tag_or_link {
458                        TagOrLink::Tag(t) => tags.insert(t),
459                        TagOrLink::Link(t) => links.insert(t),
460                    };
461                    (tags, links)
462                },
463            )
464    }
465
466    fn posting<D: Decimal + 'static>() -> impl ChumskyParser<Posting<D>> {
467        one_of("*!")
468            .then_ignore(whitespace())
469            .or_not()
470            .then(crate::account::chumksy::account())
471            .then_ignore(whitespace())
472            .then(crate::amount::chumsky::amount().or_not())
473            .then(whitespace().ignore_then(cost::<D>()).or_not())
474            .then(
475                choice((
476                    just('@')
477                        .padded()
478                        .ignore_then(crate::amount::chumsky::amount())
479                        .map(PostingPrice::Unit),
480                    just("@@")
481                        .padded()
482                        .ignore_then(crate::amount::chumsky::amount())
483                        .map(PostingPrice::Total),
484                ))
485                .or_not(),
486            )
487            .then(
488                crate::metadata::chumsky::map()
489                    .padded()
490                    .or_not()
491                    .map(Option::unwrap_or_default),
492            )
493            .map(
494                |(((((flag, account), amount), cost), price), metadata)| Posting {
495                    flag,
496                    account,
497                    amount,
498                    cost,
499                    price,
500                    metadata,
501                },
502            )
503    }
504
505    fn cost<D: Decimal + 'static>() -> impl ChumskyParser<Cost<D>> {
506        choice((
507            crate::amount::chumsky::amount()
508                .then(
509                    just(',')
510                        .padded()
511                        .ignore_then(crate::date::chumsky::date())
512                        .or_not(),
513                )
514                .map(|(amount, date)| Cost {
515                    amount: Some(amount),
516                    date,
517                }),
518            crate::date::chumsky::date()
519                .then(
520                    just(',')
521                        .padded()
522                        .ignore_then(crate::amount::chumsky::amount())
523                        .or_not(),
524                )
525                .map(|(date, amount)| Cost {
526                    amount,
527                    date: Some(date),
528                }),
529        ))
530        .or_not()
531        .padded()
532        .delimited_by(just('{'), just('}'))
533        .map(Option::unwrap_or_default)
534        .labelled("cost")
535    }
536
537    #[cfg(test)]
538    mod tests {
539        use crate::{metadata, transaction::Tag, Amount, Date, PostingPrice, Transaction};
540
541        use super::*;
542        use rstest::rstest;
543
544        #[rstest]
545        #[case("txn", None)]
546        #[case("*", Some('*'))]
547        #[case("!", Some('!'))]
548        fn should_parse_transaction_flag(#[case] input: &str, #[case] expected: Option<char>) {
549            let trx: Transaction<i32> = transaction().then_ignore(end()).parse(input).unwrap();
550            assert_eq!(trx.flag, expected);
551        }
552
553        #[rstest]
554        #[ignore = "not implemented"]
555        fn should_parse_transaction_postings() {
556            let input = "* \"foo\" #tag\n  Assets:Cash 1 CHF\n Income:A -1 CHF";
557            let trx: Transaction<i32> = transaction().then_ignore(end()).parse(input).unwrap();
558            assert_eq!(
559                trx.postings
560                    .iter()
561                    .map(|p| p.account.as_str())
562                    .collect::<Vec<_>>(),
563                vec!["Assets:Cash", "Income:A"]
564            );
565        }
566
567        #[rstest]
568        #[case("*", None, None)]
569        #[case("* \"Hello\"", None, Some("Hello"))]
570        #[case("* \"Hello\" \"World\"", Some("Hello"), Some("World"))]
571        fn should_parse_transaction_description_and_payee(
572            #[case] input: &str,
573            #[case] expected_payee: Option<&str>,
574            #[case] expected_narration: Option<&str>,
575        ) {
576            let trx: Transaction<i32> = transaction().then_ignore(end()).parse(input).unwrap();
577            assert_eq!(trx.payee.as_deref(), expected_payee);
578            assert_eq!(trx.narration.as_deref(), expected_narration);
579        }
580
581        #[rstest]
582        #[case("* \"hello\" \"world\"", &[])]
583        #[case("* \"hello\" \"world\" #foo #hello-world", &["foo", "hello-world"])]
584        #[case("* \"hello\" \"world\" #2023_05", &["2023_05"])]
585        fn should_parse_transaction_tags(#[case] input: &str, #[case] expected: &[&str]) {
586            let expected: HashSet<Tag> = expected.iter().map(|s| Tag((*s).into())).collect();
587            let trx = transaction::<i32>()
588                .then_ignore(end())
589                .parse(input)
590                .unwrap();
591            assert_eq!(trx.tags, expected);
592        }
593
594        #[rstest]
595        #[case("*", &[])]
596        #[case("* \"hello\" \"world\" ^foo.bar ^hello-world", &["foo.bar", "hello-world"])]
597        #[case("* #2023_05 ^2023-06 #2023_07", &["2023-06"])]
598        fn should_parse_transaction_links(#[case] input: &str, #[case] expected: &[&str]) {
599            let expected: HashSet<Link> = expected.iter().map(|s| Link((*s).into())).collect();
600            let trx = transaction::<i32>()
601                .then_ignore(end())
602                .parse(input)
603                .unwrap();
604            assert_eq!(trx.links, expected);
605        }
606
607        #[rstest]
608        #[case::invalid_tag("* #")]
609        #[case::invalid_tag("* #!")]
610        fn should_not_parse_invalid_transaction(#[case] input: &str) {
611            let result: Result<Transaction<i32>, _> = transaction()
612                .then_ignore(end())
613                .then_ignore(end())
614                .parse(input);
615            assert!(result.is_err(), "{result:?}");
616        }
617
618        #[rstest]
619        fn should_parse_posting_account() {
620            let posting: Posting<i32> = posting().then_ignore(end()).parse("Assets:Cash").unwrap();
621            assert_eq!(posting.account.as_str(), "Assets:Cash");
622        }
623
624        #[rstest]
625        #[case::none("Assets:Cash", None)]
626        #[case::some("Assets:Cash 42 PLN", Some(Amount { value: 42, currency: "PLN".parse().unwrap() }))]
627        fn should_parse_posting_amount(#[case] input: &str, #[case] expected: Option<Amount<i32>>) {
628            let posting: Posting<i32> = posting().then_ignore(end()).parse(input).unwrap();
629            assert_eq!(posting.amount, expected);
630        }
631
632        #[rstest]
633        #[case::no_flag("Assets:Cash 1 CHF", None)]
634        #[case::cleared("* Assets:Cash 1 CHF", Some('*'))]
635        #[case::pending("! Assets:Cash 1 CHF", Some('!'))]
636        fn should_parse_posting_flag(#[case] input: &str, #[case] expected: Option<char>) {
637            let posting: Posting<i32> = posting().then_ignore(end()).parse(input).unwrap();
638            assert_eq!(posting.flag, expected);
639        }
640
641        #[rstest]
642        #[case::none("Assets:Cash 1 CHF", None)]
643        #[case::unit("Assets:Cash 1 CHF @ 2 EUR", Some(PostingPrice::Unit(Amount { value: 2, currency: "EUR".parse().unwrap() })))]
644        #[case::total("Assets:Cash 1 CHF @@ 2 EUR", Some(PostingPrice::Total(Amount { value: 2, currency: "EUR".parse().unwrap() })))]
645        fn should_parse_posting_price(
646            #[case] input: &str,
647            #[case] expected: Option<PostingPrice<i32>>,
648        ) {
649            let posting: Posting<i32> = posting().then_ignore(end()).parse(input).unwrap();
650            assert_eq!(posting.price, expected);
651        }
652
653        #[rstest]
654        #[case::none("Assets:Cash 1 CHF", None)]
655        #[case::empty("Assets:Cash 1 CHF {}", Some(Cost::default()))]
656        #[case::some("Assets:Cash 1 CHF {2023-03-03}", Some(Cost { date: Some(Date::new(2023,3,3)), ..Cost::default() }))]
657        #[case::some_before_price("Assets:Cash 1 CHF {2023-03-03} @ 3 PLN", Some(Cost { date: Some(Date::new(2023,3,3)), ..Cost::default() }))]
658        fn should_parse_posting_cost(#[case] input: &str, #[case] expected: Option<Cost<i32>>) {
659            let posting: Posting<i32> = posting().then_ignore(end()).parse(input).unwrap();
660            assert_eq!(posting.cost, expected);
661        }
662
663        #[rstest]
664        fn should_parse_posting_metadata() {
665            let input = "Assets:Cash 10 CHF @ 40 PLN\n  hello: \"world\"";
666            let posting: Posting<i32> = posting().then_ignore(end()).parse(input).unwrap();
667            assert_eq!(
668                posting.metadata.get("hello"),
669                Some(&metadata::Value::String("world".into()))
670            );
671        }
672
673        #[rstest]
674        fn should_parse_empty_cost(#[values("{}", "{ }")] input: &str) {
675            let cost: Cost<i32> = cost().then_ignore(end()).parse(input).unwrap();
676            assert_eq!(cost.amount, None);
677            assert_eq!(cost.date, None);
678        }
679
680        #[rstest]
681        fn should_parse_cost_amount(
682            #[values("{1 EUR}", "{ 1 EUR }", "{2024-03-03, 1 EUR}")] input: &str,
683        ) {
684            let cost: Cost<i32> = cost().then_ignore(end()).parse(input).unwrap();
685            let amount = cost.amount.unwrap();
686            assert_eq!(amount.value, 1);
687            assert_eq!(amount.currency.as_str(), "EUR");
688        }
689
690        #[rstest]
691        fn should_parse_cost_date(
692            #[values("{2024-03-02}", "{ 1 EUR , 2024-03-02 }", "{ 2024-03-02, 2 EUR }")]
693            input: &str,
694        ) {
695            let cost: Cost<i32> = cost().then_ignore(end()).parse(input).unwrap();
696            assert_eq!(
697                cost.date,
698                Some(Date {
699                    year: 2024,
700                    month: 3,
701                    day: 2,
702                })
703            );
704        }
705
706        #[rstest]
707        #[case::duplicated_date("{2023-03-03, 2023-03-04}")]
708        #[case::duplicated_amount("{1 EUR, 2 CHF}")]
709        fn should_not_parse_invalid_cost(#[case] input: &str) {
710            let result: Result<Cost<i32>, _> = cost().then_ignore(end()).parse(input);
711            assert!(result.is_err(), "{result:?}");
712        }
713    }
714}