hledger_parser/directive/transaction/
posting.rs

1use chumsky::prelude::*;
2
3mod assertion;
4
5use crate::component::account_name::account_name;
6use crate::component::amount::{amount, Amount};
7use crate::component::price::{amount_price, AmountPrice};
8use crate::component::whitespace::whitespace;
9use crate::directive::transaction::posting::assertion::assertion;
10use crate::directive::transaction::status::{status, Status};
11use crate::state::State;
12use crate::utils::end_of_line;
13
14pub use crate::directive::transaction::posting::assertion::Assertion;
15
16#[derive(Clone, Debug, PartialEq)]
17pub struct Posting {
18    pub status: Option<Status>,
19    pub account_name: Vec<String>,
20    pub is_virtual: bool,
21    pub amount: Option<Amount>,
22    pub price: Option<AmountPrice>,
23    pub assertion: Option<Assertion>,
24}
25
26#[must_use]
27pub fn posting<'a>() -> impl Parser<'a, &'a str, Posting, extra::Full<Rich<'a, char>, State, ()>> {
28    let posting_amount = whitespace().repeated().at_least(2).ignore_then(amount());
29    let posting_price = whitespace().repeated().ignore_then(amount_price());
30    let posting_assertion = whitespace().repeated().ignore_then(assertion());
31    let account_name = account_name()
32        .delimited_by(just('('), just(')'))
33        .map(|name| (name, true))
34        .or(account_name().map(|name| (name, false)));
35    whitespace()
36        .repeated()
37        .at_least(1)
38        .ignore_then(status().then_ignore(whitespace()).or_not())
39        .then(account_name)
40        .then(posting_amount.or_not())
41        .then(posting_price.or_not())
42        .then(posting_assertion.or_not())
43        .then_ignore(end_of_line())
44        .map(
45            |((((status, (account_name, is_virtual)), amount), price), assertion)| Posting {
46                status,
47                account_name,
48                is_virtual,
49                amount,
50                price,
51                assertion,
52            },
53        )
54}
55
56#[cfg(test)]
57mod tests {
58    use rust_decimal::Decimal;
59
60    use super::*;
61
62    #[test]
63    fn full() {
64        let result = posting()
65            .then_ignore(end())
66            .parse(" ! assets:bank:checking   $1")
67            .into_result();
68        assert_eq!(
69            result,
70            Ok(Posting {
71                status: Some(Status::Pending),
72                account_name: vec![
73                    String::from("assets"),
74                    String::from("bank"),
75                    String::from("checking")
76                ],
77                amount: Some(Amount {
78                    quantity: Decimal::new(1, 0),
79                    commodity: String::from("$"),
80                }),
81                price: None,
82                assertion: None,
83                is_virtual: false,
84            })
85        );
86    }
87
88    #[test]
89    fn no_amount() {
90        let result = posting()
91            .then_ignore(end())
92            .parse(" ! assets:bank:checking")
93            .into_result();
94        assert_eq!(
95            result,
96            Ok(Posting {
97                status: Some(Status::Pending),
98                account_name: vec![
99                    String::from("assets"),
100                    String::from("bank"),
101                    String::from("checking")
102                ],
103                amount: None,
104                price: None,
105                assertion: None,
106                is_virtual: false,
107            })
108        );
109    }
110
111    #[test]
112    fn no_status() {
113        let result = posting()
114            .then_ignore(end())
115            .parse(" assets:bank:checking   $1")
116            .into_result();
117        assert_eq!(
118            result,
119            Ok(Posting {
120                status: None,
121                account_name: vec![
122                    String::from("assets"),
123                    String::from("bank"),
124                    String::from("checking"),
125                ],
126                amount: Some(Amount {
127                    quantity: Decimal::new(1, 0),
128                    commodity: String::from("$"),
129                }),
130                price: None,
131                assertion: None,
132                is_virtual: false,
133            })
134        );
135    }
136
137    #[test]
138    fn with_comment() {
139        let result = posting()
140            .then_ignore(end())
141            .parse(
142                " assets:bank:checking  ; some comment
143                                    ; continuation of the same comment",
144            )
145            .into_result();
146        assert_eq!(
147            result,
148            Ok(Posting {
149                status: None,
150                account_name: vec![
151                    String::from("assets"),
152                    String::from("bank"),
153                    String::from("checking"),
154                ],
155                amount: None,
156                price: None,
157                assertion: None,
158                is_virtual: false,
159            })
160        );
161    }
162
163    #[test]
164    fn no_status_no_amount() {
165        let result = posting()
166            .then_ignore(end())
167            .parse(" assets:bank:checking")
168            .into_result();
169        assert_eq!(
170            result,
171            Ok(Posting {
172                status: None,
173                account_name: vec![
174                    String::from("assets"),
175                    String::from("bank"),
176                    String::from("checking"),
177                ],
178                amount: None,
179                price: None,
180                assertion: None,
181                is_virtual: false,
182            })
183        );
184    }
185
186    #[test]
187    fn with_price_assertion() {
188        let result = posting()
189            .then_ignore(end())
190            .parse(" assets:bank:checking  1 EUR@@1 USD=1 USD")
191            .into_result();
192        assert_eq!(
193            result,
194            Ok(Posting {
195                status: None,
196                account_name: vec![
197                    String::from("assets"),
198                    String::from("bank"),
199                    String::from("checking"),
200                ],
201                amount: Some(Amount {
202                    quantity: Decimal::new(1, 0),
203                    commodity: String::from("EUR"),
204                }),
205                price: Some(AmountPrice::Total(Amount {
206                    quantity: Decimal::new(1, 0),
207                    commodity: String::from("USD"),
208                })),
209                assertion: Some(Assertion {
210                    price: None,
211                    amount: Amount {
212                        quantity: Decimal::new(1, 0),
213                        commodity: String::from("USD"),
214                    },
215                    is_subaccount_inclusive: false,
216                    is_strict: false,
217                }),
218                is_virtual: false,
219            })
220        );
221    }
222
223    #[test]
224    fn with_assertion() {
225        let result = posting()
226            .then_ignore(end())
227            .parse(" assets:bank:checking  1 USD == 1 USD")
228            .into_result();
229        assert_eq!(
230            result,
231            Ok(Posting {
232                status: None,
233                account_name: vec![
234                    String::from("assets"),
235                    String::from("bank"),
236                    String::from("checking"),
237                ],
238                amount: Some(Amount {
239                    quantity: Decimal::new(1, 0),
240                    commodity: String::from("USD"),
241                }),
242                price: None,
243                assertion: Some(Assertion {
244                    price: None,
245                    amount: Amount {
246                        quantity: Decimal::new(1, 0),
247                        commodity: String::from("USD"),
248                    },
249                    is_subaccount_inclusive: false,
250                    is_strict: true,
251                }),
252                is_virtual: false,
253            })
254        );
255    }
256
257    #[test]
258    fn with_price() {
259        let result = posting()
260            .then_ignore(end())
261            .parse(" assets:bank:checking  1 USD @ 1 EUR")
262            .into_result();
263        assert_eq!(
264            result,
265            Ok(Posting {
266                status: None,
267                account_name: vec![
268                    String::from("assets"),
269                    String::from("bank"),
270                    String::from("checking"),
271                ],
272                amount: Some(Amount {
273                    quantity: Decimal::new(1, 0),
274                    commodity: String::from("USD"),
275                }),
276                price: Some(AmountPrice::Unit(Amount {
277                    quantity: Decimal::new(1, 0),
278                    commodity: String::from("EUR"),
279                })),
280                assertion: None,
281                is_virtual: false,
282            })
283        );
284    }
285
286    #[test]
287    fn virtual_posting() {
288        let result = posting()
289            .then_ignore(end())
290            .parse(" (assets:bank:checking)  $1")
291            .into_result();
292        assert_eq!(
293            result,
294            Ok(Posting {
295                status: None,
296                account_name: vec![
297                    String::from("assets"),
298                    String::from("bank"),
299                    String::from("checking"),
300                ],
301                amount: Some(Amount {
302                    quantity: Decimal::new(1, 0),
303                    commodity: String::from("$"),
304                }),
305                price: None,
306                assertion: None,
307                is_virtual: true,
308            })
309        );
310    }
311
312    #[test]
313    fn not_enough_spaces() {
314        let result = posting()
315            .then_ignore(end())
316            .parse(" assets:bank:checking $1")
317            .into_result();
318        assert_eq!(
319            result,
320            Ok(Posting {
321                status: None,
322                account_name: vec![
323                    String::from("assets"),
324                    String::from("bank"),
325                    String::from("checking $1"),
326                ],
327                amount: None,
328                price: None,
329                assertion: None,
330                is_virtual: false,
331            })
332        );
333    }
334
335    #[test]
336    fn no_ident() {
337        let result = posting()
338            .then_ignore(end())
339            .parse("assets:bank:checking $1")
340            .into_result();
341        assert!(result.is_err());
342    }
343}