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}