1use rustledger_core::{
4 Amount, Balance, Close, Commodity, CostSpec, Custom, Decimal, Directive, Document, Event,
5 IncompleteAmount, MetaValue, NaiveDate, Note, Open, Pad, Posting, Price, PriceAnnotation,
6 Query, Transaction,
7};
8
9use crate::types::{
10 AmountData, BalanceData, CloseData, CommodityData, CostData, CustomData, DirectiveData,
11 DirectiveWrapper, DocumentData, EventData, MetaValueData, NoteData, OpenData, PadData,
12 PostingData, PriceAnnotationData, PriceData, QueryData, TransactionData,
13};
14
15pub fn directive_to_wrapper(directive: &Directive) -> DirectiveWrapper {
17 match directive {
18 Directive::Transaction(txn) => DirectiveWrapper {
19 directive_type: "transaction".to_string(),
20 date: txn.date.to_string(),
21 data: DirectiveData::Transaction(transaction_to_data(txn)),
22 },
23 Directive::Balance(bal) => DirectiveWrapper {
24 directive_type: "balance".to_string(),
25 date: bal.date.to_string(),
26 data: DirectiveData::Balance(balance_to_data(bal)),
27 },
28 Directive::Open(open) => DirectiveWrapper {
29 directive_type: "open".to_string(),
30 date: open.date.to_string(),
31 data: DirectiveData::Open(open_to_data(open)),
32 },
33 Directive::Close(close) => DirectiveWrapper {
34 directive_type: "close".to_string(),
35 date: close.date.to_string(),
36 data: DirectiveData::Close(close_to_data(close)),
37 },
38 Directive::Commodity(comm) => DirectiveWrapper {
39 directive_type: "commodity".to_string(),
40 date: comm.date.to_string(),
41 data: DirectiveData::Commodity(commodity_to_data(comm)),
42 },
43 Directive::Pad(pad) => DirectiveWrapper {
44 directive_type: "pad".to_string(),
45 date: pad.date.to_string(),
46 data: DirectiveData::Pad(pad_to_data(pad)),
47 },
48 Directive::Event(event) => DirectiveWrapper {
49 directive_type: "event".to_string(),
50 date: event.date.to_string(),
51 data: DirectiveData::Event(event_to_data(event)),
52 },
53 Directive::Note(note) => DirectiveWrapper {
54 directive_type: "note".to_string(),
55 date: note.date.to_string(),
56 data: DirectiveData::Note(note_to_data(note)),
57 },
58 Directive::Document(doc) => DirectiveWrapper {
59 directive_type: "document".to_string(),
60 date: doc.date.to_string(),
61 data: DirectiveData::Document(document_to_data(doc)),
62 },
63 Directive::Price(price) => DirectiveWrapper {
64 directive_type: "price".to_string(),
65 date: price.date.to_string(),
66 data: DirectiveData::Price(price_to_data(price)),
67 },
68 Directive::Query(query) => DirectiveWrapper {
69 directive_type: "query".to_string(),
70 date: query.date.to_string(),
71 data: DirectiveData::Query(query_to_data(query)),
72 },
73 Directive::Custom(custom) => DirectiveWrapper {
74 directive_type: "custom".to_string(),
75 date: custom.date.to_string(),
76 data: DirectiveData::Custom(custom_to_data(custom)),
77 },
78 }
79}
80
81fn transaction_to_data(txn: &Transaction) -> TransactionData {
82 TransactionData {
83 flag: txn.flag.to_string(),
84 payee: txn.payee.as_ref().map(ToString::to_string),
85 narration: txn.narration.to_string(),
86 tags: txn.tags.iter().map(ToString::to_string).collect(),
87 links: txn.links.iter().map(ToString::to_string).collect(),
88 metadata: txn
89 .meta
90 .iter()
91 .map(|(k, v)| (k.clone(), meta_value_to_data(v)))
92 .collect(),
93 postings: txn.postings.iter().map(posting_to_data).collect(),
94 }
95}
96
97fn posting_to_data(posting: &Posting) -> PostingData {
98 PostingData {
99 account: posting.account.to_string(),
100 units: posting.units.as_ref().and_then(incomplete_amount_to_data),
101 cost: posting.cost.as_ref().map(cost_to_data),
102 price: posting.price.as_ref().map(price_annotation_to_data),
103 flag: posting.flag.map(|c| c.to_string()),
104 metadata: posting
105 .meta
106 .iter()
107 .map(|(k, v)| (k.clone(), meta_value_to_data(v)))
108 .collect(),
109 }
110}
111
112fn incomplete_amount_to_data(incomplete: &IncompleteAmount) -> Option<AmountData> {
113 match incomplete {
114 IncompleteAmount::Complete(amount) => Some(amount_to_data(amount)),
115 IncompleteAmount::CurrencyOnly(currency) => Some(AmountData {
116 number: String::new(), currency: currency.to_string(),
118 }),
119 IncompleteAmount::NumberOnly(number) => Some(AmountData {
120 number: number.to_string(),
121 currency: String::new(), }),
123 }
124}
125
126fn amount_to_data(amount: &Amount) -> AmountData {
127 AmountData {
128 number: amount.number.to_string(),
129 currency: amount.currency.to_string(),
130 }
131}
132
133fn cost_to_data(cost: &CostSpec) -> CostData {
134 CostData {
135 number_per: cost.number_per.map(|n| n.to_string()),
136 number_total: cost.number_total.map(|n| n.to_string()),
137 currency: cost.currency.as_ref().map(ToString::to_string),
138 date: cost.date.map(|d| d.to_string()),
139 label: cost.label.clone(),
140 merge: cost.merge,
141 }
142}
143
144fn price_annotation_to_data(price: &PriceAnnotation) -> PriceAnnotationData {
145 match price {
146 PriceAnnotation::Unit(amount) => PriceAnnotationData {
147 is_total: false,
148 amount: Some(amount_to_data(amount)),
149 number: None,
150 currency: None,
151 },
152 PriceAnnotation::Total(amount) => PriceAnnotationData {
153 is_total: true,
154 amount: Some(amount_to_data(amount)),
155 number: None,
156 currency: None,
157 },
158 PriceAnnotation::UnitIncomplete(inc) => PriceAnnotationData {
159 is_total: false,
160 amount: inc.as_amount().map(amount_to_data),
161 number: inc.number().map(|n| n.to_string()),
162 currency: inc.currency().map(String::from),
163 },
164 PriceAnnotation::TotalIncomplete(inc) => PriceAnnotationData {
165 is_total: true,
166 amount: inc.as_amount().map(amount_to_data),
167 number: inc.number().map(|n| n.to_string()),
168 currency: inc.currency().map(String::from),
169 },
170 PriceAnnotation::UnitEmpty => PriceAnnotationData {
171 is_total: false,
172 amount: None,
173 number: None,
174 currency: None,
175 },
176 PriceAnnotation::TotalEmpty => PriceAnnotationData {
177 is_total: true,
178 amount: None,
179 number: None,
180 currency: None,
181 },
182 }
183}
184
185fn meta_value_to_data(value: &MetaValue) -> MetaValueData {
186 match value {
187 MetaValue::String(s) => MetaValueData::String(s.clone()),
188 MetaValue::Number(n) => MetaValueData::Number(n.to_string()),
189 MetaValue::Date(d) => MetaValueData::Date(d.to_string()),
190 MetaValue::Account(a) => MetaValueData::Account(a.clone()),
191 MetaValue::Currency(c) => MetaValueData::Currency(c.clone()),
192 MetaValue::Tag(t) => MetaValueData::Tag(t.clone()),
193 MetaValue::Link(l) => MetaValueData::Link(l.clone()),
194 MetaValue::Amount(a) => MetaValueData::Amount(amount_to_data(a)),
195 MetaValue::Bool(b) => MetaValueData::Bool(*b),
196 MetaValue::None => MetaValueData::String(String::new()),
197 }
198}
199
200fn balance_to_data(bal: &Balance) -> BalanceData {
201 BalanceData {
202 account: bal.account.to_string(),
203 amount: amount_to_data(&bal.amount),
204 tolerance: bal.tolerance.map(|t| t.to_string()),
205 }
206}
207
208fn open_to_data(open: &Open) -> OpenData {
209 OpenData {
210 account: open.account.to_string(),
211 currencies: open.currencies.iter().map(ToString::to_string).collect(),
212 booking: open.booking.clone(),
213 }
214}
215
216fn close_to_data(close: &Close) -> CloseData {
217 CloseData {
218 account: close.account.to_string(),
219 }
220}
221
222fn commodity_to_data(comm: &Commodity) -> CommodityData {
223 CommodityData {
224 currency: comm.currency.to_string(),
225 metadata: comm
226 .meta
227 .iter()
228 .map(|(k, v)| (k.clone(), meta_value_to_data(v)))
229 .collect(),
230 }
231}
232
233fn pad_to_data(pad: &Pad) -> PadData {
234 PadData {
235 account: pad.account.to_string(),
236 source_account: pad.source_account.to_string(),
237 }
238}
239
240fn event_to_data(event: &Event) -> EventData {
241 EventData {
242 event_type: event.event_type.clone(),
243 value: event.value.clone(),
244 }
245}
246
247fn note_to_data(note: &Note) -> NoteData {
248 NoteData {
249 account: note.account.to_string(),
250 comment: note.comment.clone(),
251 }
252}
253
254fn document_to_data(doc: &Document) -> DocumentData {
255 DocumentData {
256 account: doc.account.to_string(),
257 path: doc.path.clone(),
258 }
259}
260
261fn price_to_data(price: &Price) -> PriceData {
262 PriceData {
263 currency: price.currency.to_string(),
264 amount: amount_to_data(&price.amount),
265 }
266}
267
268fn query_to_data(query: &Query) -> QueryData {
269 QueryData {
270 name: query.name.clone(),
271 query: query.query.clone(),
272 }
273}
274
275fn custom_to_data(custom: &Custom) -> CustomData {
276 CustomData {
277 custom_type: custom.custom_type.clone(),
278 values: custom.values.iter().map(|v| format!("{v:?}")).collect(),
279 }
280}
281
282pub fn directives_to_wrappers(directives: &[Directive]) -> Vec<DirectiveWrapper> {
284 directives.iter().map(directive_to_wrapper).collect()
285}
286
287#[derive(Debug, Clone, thiserror::Error)]
289pub enum ConversionError {
290 #[error("invalid date format: {0}")]
292 InvalidDate(String),
293 #[error("invalid number format: {0}")]
295 InvalidNumber(String),
296 #[error("invalid flag: {0}")]
298 InvalidFlag(String),
299 #[error("unknown directive type: {0}")]
301 UnknownDirective(String),
302}
303
304pub fn wrapper_to_directive(wrapper: &DirectiveWrapper) -> Result<Directive, ConversionError> {
306 let date = NaiveDate::parse_from_str(&wrapper.date, "%Y-%m-%d")
307 .map_err(|_| ConversionError::InvalidDate(wrapper.date.clone()))?;
308
309 match &wrapper.data {
310 DirectiveData::Transaction(data) => {
311 Ok(Directive::Transaction(data_to_transaction(data, date)?))
312 }
313 DirectiveData::Balance(data) => Ok(Directive::Balance(data_to_balance(data, date)?)),
314 DirectiveData::Open(data) => Ok(Directive::Open(data_to_open(data, date))),
315 DirectiveData::Close(data) => Ok(Directive::Close(data_to_close(data, date))),
316 DirectiveData::Commodity(data) => Ok(Directive::Commodity(data_to_commodity(data, date))),
317 DirectiveData::Pad(data) => Ok(Directive::Pad(data_to_pad(data, date))),
318 DirectiveData::Event(data) => Ok(Directive::Event(data_to_event(data, date))),
319 DirectiveData::Note(data) => Ok(Directive::Note(data_to_note(data, date))),
320 DirectiveData::Document(data) => Ok(Directive::Document(data_to_document(data, date))),
321 DirectiveData::Price(data) => Ok(Directive::Price(data_to_price(data, date)?)),
322 DirectiveData::Query(data) => Ok(Directive::Query(data_to_query(data, date))),
323 DirectiveData::Custom(data) => Ok(Directive::Custom(data_to_custom(data, date))),
324 }
325}
326
327fn data_to_transaction(
328 data: &TransactionData,
329 date: NaiveDate,
330) -> Result<Transaction, ConversionError> {
331 let flag = match data.flag.as_str() {
332 "*" => '*',
333 "!" => '!',
334 "P" => 'P',
335 other => {
336 if let Some(c) = other.chars().next() {
337 c
338 } else {
339 return Err(ConversionError::InvalidFlag(other.to_string()));
340 }
341 }
342 };
343
344 let postings = data
345 .postings
346 .iter()
347 .map(data_to_posting)
348 .collect::<Result<Vec<_>, _>>()?;
349
350 let meta = data
351 .metadata
352 .iter()
353 .map(|(k, v)| (k.clone(), data_to_meta_value(v)))
354 .collect();
355
356 Ok(Transaction {
357 date,
358 flag,
359 payee: data.payee.as_ref().map(|p| p.as_str().into()),
360 narration: data.narration.as_str().into(),
361 tags: data.tags.iter().map(|t| t.as_str().into()).collect(),
362 links: data.links.iter().map(|l| l.as_str().into()).collect(),
363 meta,
364 postings,
365 })
366}
367
368fn data_to_posting(data: &PostingData) -> Result<Posting, ConversionError> {
369 let units = data
370 .units
371 .as_ref()
372 .map(data_to_incomplete_amount)
373 .transpose()?;
374 let cost = data.cost.as_ref().map(data_to_cost).transpose()?;
375 let price = data
376 .price
377 .as_ref()
378 .map(data_to_price_annotation)
379 .transpose()?;
380 let flag = data.flag.as_ref().and_then(|s| s.chars().next());
381
382 let meta = data
383 .metadata
384 .iter()
385 .map(|(k, v)| (k.clone(), data_to_meta_value(v)))
386 .collect();
387
388 Ok(Posting {
389 account: data.account.clone().into(),
390 units,
391 cost,
392 price,
393 flag,
394 meta,
395 })
396}
397
398fn data_to_incomplete_amount(data: &AmountData) -> Result<IncompleteAmount, ConversionError> {
399 if data.number.is_empty() && !data.currency.is_empty() {
400 Ok(IncompleteAmount::CurrencyOnly(data.currency.clone().into()))
401 } else if !data.number.is_empty() && data.currency.is_empty() {
402 let number = Decimal::from_str_exact(&data.number)
403 .map_err(|_| ConversionError::InvalidNumber(data.number.clone()))?;
404 Ok(IncompleteAmount::NumberOnly(number))
405 } else {
406 let amount = data_to_amount(data)?;
407 Ok(IncompleteAmount::Complete(amount))
408 }
409}
410
411fn data_to_amount(data: &AmountData) -> Result<Amount, ConversionError> {
412 let number = Decimal::from_str_exact(&data.number)
413 .map_err(|_| ConversionError::InvalidNumber(data.number.clone()))?;
414 Ok(Amount::new(number, &data.currency))
415}
416
417fn data_to_cost(data: &CostData) -> Result<CostSpec, ConversionError> {
418 let number_per = data
419 .number_per
420 .as_ref()
421 .map(|s| Decimal::from_str_exact(s))
422 .transpose()
423 .map_err(|_| ConversionError::InvalidNumber(data.number_per.clone().unwrap_or_default()))?;
424
425 let number_total = data
426 .number_total
427 .as_ref()
428 .map(|s| Decimal::from_str_exact(s))
429 .transpose()
430 .map_err(|_| {
431 ConversionError::InvalidNumber(data.number_total.clone().unwrap_or_default())
432 })?;
433
434 let date = data
435 .date
436 .as_ref()
437 .map(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d"))
438 .transpose()
439 .map_err(|_| ConversionError::InvalidDate(data.date.clone().unwrap_or_default()))?;
440
441 Ok(CostSpec {
442 number_per,
443 number_total,
444 currency: data.currency.as_ref().map(|c| c.clone().into()),
445 date,
446 label: data.label.clone(),
447 merge: data.merge,
448 })
449}
450
451fn data_to_price_annotation(
452 data: &PriceAnnotationData,
453) -> Result<PriceAnnotation, ConversionError> {
454 if let Some(amount_data) = &data.amount {
455 let amount = data_to_amount(amount_data)?;
456 if data.is_total {
457 Ok(PriceAnnotation::Total(amount))
458 } else {
459 Ok(PriceAnnotation::Unit(amount))
460 }
461 } else if data.number.is_some() || data.currency.is_some() {
462 let incomplete = if let (Some(num_str), Some(cur)) = (&data.number, &data.currency) {
464 let number = Decimal::from_str_exact(num_str)
465 .map_err(|_| ConversionError::InvalidNumber(num_str.clone()))?;
466 IncompleteAmount::Complete(Amount::new(number, cur))
467 } else if let Some(num_str) = &data.number {
468 let number = Decimal::from_str_exact(num_str)
469 .map_err(|_| ConversionError::InvalidNumber(num_str.clone()))?;
470 IncompleteAmount::NumberOnly(number)
471 } else if let Some(cur) = &data.currency {
472 IncompleteAmount::CurrencyOnly(cur.clone().into())
473 } else {
474 unreachable!()
475 };
476 if data.is_total {
477 Ok(PriceAnnotation::TotalIncomplete(incomplete))
478 } else {
479 Ok(PriceAnnotation::UnitIncomplete(incomplete))
480 }
481 } else {
482 if data.is_total {
484 Ok(PriceAnnotation::TotalEmpty)
485 } else {
486 Ok(PriceAnnotation::UnitEmpty)
487 }
488 }
489}
490
491fn data_to_meta_value(data: &MetaValueData) -> MetaValue {
492 match data {
493 MetaValueData::String(s) => MetaValue::String(s.clone()),
494 MetaValueData::Number(s) => {
495 if let Ok(n) = Decimal::from_str_exact(s) {
496 MetaValue::Number(n)
497 } else {
498 MetaValue::String(s.clone())
499 }
500 }
501 MetaValueData::Date(s) => {
502 if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
503 MetaValue::Date(d)
504 } else {
505 MetaValue::String(s.clone())
506 }
507 }
508 MetaValueData::Account(s) => MetaValue::Account(s.clone()),
509 MetaValueData::Currency(s) => MetaValue::Currency(s.clone()),
510 MetaValueData::Tag(s) => MetaValue::Tag(s.clone()),
511 MetaValueData::Link(s) => MetaValue::Link(s.clone()),
512 MetaValueData::Amount(a) => {
513 if let Ok(amount) = data_to_amount(a) {
514 MetaValue::Amount(amount)
515 } else {
516 MetaValue::String(format!("{} {}", a.number, a.currency))
517 }
518 }
519 MetaValueData::Bool(b) => MetaValue::Bool(*b),
520 }
521}
522
523fn data_to_balance(data: &BalanceData, date: NaiveDate) -> Result<Balance, ConversionError> {
524 let amount = data_to_amount(&data.amount)?;
525 let tolerance = data
526 .tolerance
527 .as_ref()
528 .map(|s| Decimal::from_str_exact(s))
529 .transpose()
530 .map_err(|_| ConversionError::InvalidNumber(data.tolerance.clone().unwrap_or_default()))?;
531
532 Ok(Balance {
533 date,
534 account: data.account.clone().into(),
535 amount,
536 tolerance,
537 meta: Default::default(),
538 })
539}
540
541fn data_to_open(data: &OpenData, date: NaiveDate) -> Open {
542 Open {
543 date,
544 account: data.account.clone().into(),
545 currencies: data.currencies.iter().map(|c| c.clone().into()).collect(),
546 booking: data.booking.clone(),
547 meta: Default::default(),
548 }
549}
550
551fn data_to_close(data: &CloseData, date: NaiveDate) -> Close {
552 Close {
553 date,
554 account: data.account.clone().into(),
555 meta: Default::default(),
556 }
557}
558
559fn data_to_commodity(data: &CommodityData, date: NaiveDate) -> Commodity {
560 Commodity {
561 date,
562 currency: data.currency.clone().into(),
563 meta: data
564 .metadata
565 .iter()
566 .map(|(k, v)| (k.clone(), data_to_meta_value(v)))
567 .collect(),
568 }
569}
570
571fn data_to_pad(data: &PadData, date: NaiveDate) -> Pad {
572 Pad {
573 date,
574 account: data.account.clone().into(),
575 source_account: data.source_account.clone().into(),
576 meta: Default::default(),
577 }
578}
579
580fn data_to_event(data: &EventData, date: NaiveDate) -> Event {
581 Event {
582 date,
583 event_type: data.event_type.clone(),
584 value: data.value.clone(),
585 meta: Default::default(),
586 }
587}
588
589fn data_to_note(data: &NoteData, date: NaiveDate) -> Note {
590 Note {
591 date,
592 account: data.account.clone().into(),
593 comment: data.comment.clone(),
594 meta: Default::default(),
595 }
596}
597
598fn data_to_document(data: &DocumentData, date: NaiveDate) -> Document {
599 Document {
600 date,
601 account: data.account.clone().into(),
602 path: data.path.clone(),
603 tags: Vec::new(),
604 links: Vec::new(),
605 meta: Default::default(),
606 }
607}
608
609fn data_to_price(data: &PriceData, date: NaiveDate) -> Result<Price, ConversionError> {
610 let amount = data_to_amount(&data.amount)?;
611 Ok(Price {
612 date,
613 currency: data.currency.clone().into(),
614 amount,
615 meta: Default::default(),
616 })
617}
618
619fn data_to_query(data: &QueryData, date: NaiveDate) -> Query {
620 Query {
621 date,
622 name: data.name.clone(),
623 query: data.query.clone(),
624 meta: Default::default(),
625 }
626}
627
628fn data_to_custom(data: &CustomData, date: NaiveDate) -> Custom {
629 Custom {
630 date,
631 custom_type: data.custom_type.clone(),
632 values: data
633 .values
634 .iter()
635 .map(|s| MetaValue::String(s.clone()))
636 .collect(),
637 meta: Default::default(),
638 }
639}
640
641pub fn wrappers_to_directives(
643 wrappers: &[DirectiveWrapper],
644) -> Result<Vec<Directive>, ConversionError> {
645 wrappers.iter().map(wrapper_to_directive).collect()
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651 use std::collections::HashMap;
652 use std::str::FromStr;
653
654 fn dec(s: &str) -> Decimal {
655 Decimal::from_str(s).unwrap()
656 }
657
658 #[test]
659 fn test_roundtrip_transaction() {
660 let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
661 let txn = Transaction {
662 date,
663 flag: '*',
664 payee: Some("Grocery Store".into()),
665 narration: "Weekly groceries".into(),
666 tags: vec!["food".into()],
667 links: vec!["grocery-2024".into()],
668 meta: HashMap::new(),
669 postings: vec![
670 Posting {
671 account: "Expenses:Food".into(),
672 units: Some(IncompleteAmount::Complete(Amount::new(dec("50.00"), "USD"))),
673 cost: None,
674 price: None,
675 flag: None,
676 meta: HashMap::new(),
677 },
678 Posting {
679 account: "Assets:Checking".into(),
680 units: None,
681 cost: None,
682 price: None,
683 flag: None,
684 meta: HashMap::new(),
685 },
686 ],
687 };
688
689 let directive = Directive::Transaction(txn);
690 let wrapper = directive_to_wrapper(&directive);
691 let roundtrip = wrapper_to_directive(&wrapper).unwrap();
692
693 if let (Directive::Transaction(orig), Directive::Transaction(rt)) = (&directive, &roundtrip)
694 {
695 assert_eq!(orig.date, rt.date);
696 assert_eq!(orig.flag, rt.flag);
697 assert_eq!(orig.payee, rt.payee);
698 assert_eq!(orig.narration, rt.narration);
699 assert_eq!(orig.tags, rt.tags);
700 assert_eq!(orig.links, rt.links);
701 assert_eq!(orig.postings.len(), rt.postings.len());
702 } else {
703 panic!("Expected Transaction directive");
704 }
705 }
706
707 #[test]
708 fn test_roundtrip_balance() {
709 let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
710 let balance = Balance {
711 date,
712 account: "Assets:Checking".into(),
713 amount: Amount::new(dec("1000.00"), "USD"),
714 tolerance: Some(dec("0.01")),
715 meta: HashMap::new(),
716 };
717
718 let directive = Directive::Balance(balance);
719 let wrapper = directive_to_wrapper(&directive);
720 let roundtrip = wrapper_to_directive(&wrapper).unwrap();
721
722 if let (Directive::Balance(orig), Directive::Balance(rt)) = (&directive, &roundtrip) {
723 assert_eq!(orig.date, rt.date);
724 assert_eq!(orig.account, rt.account);
725 assert_eq!(orig.amount, rt.amount);
726 assert_eq!(orig.tolerance, rt.tolerance);
727 } else {
728 panic!("Expected Balance directive");
729 }
730 }
731
732 #[test]
733 fn test_roundtrip_open() {
734 let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
735 let open = Open {
736 date,
737 account: "Assets:Checking".into(),
738 currencies: vec!["USD".into(), "EUR".into()],
739 booking: Some("FIFO".to_string()),
740 meta: HashMap::new(),
741 };
742
743 let directive = Directive::Open(open);
744 let wrapper = directive_to_wrapper(&directive);
745 let roundtrip = wrapper_to_directive(&wrapper).unwrap();
746
747 if let (Directive::Open(orig), Directive::Open(rt)) = (&directive, &roundtrip) {
748 assert_eq!(orig.date, rt.date);
749 assert_eq!(orig.account, rt.account);
750 assert_eq!(orig.currencies, rt.currencies);
751 assert_eq!(orig.booking, rt.booking);
752 } else {
753 panic!("Expected Open directive");
754 }
755 }
756
757 #[test]
758 fn test_roundtrip_price() {
759 let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
760 let price = Price {
761 date,
762 currency: "AAPL".into(),
763 amount: Amount::new(dec("185.50"), "USD"),
764 meta: HashMap::new(),
765 };
766
767 let directive = Directive::Price(price);
768 let wrapper = directive_to_wrapper(&directive);
769 let roundtrip = wrapper_to_directive(&wrapper).unwrap();
770
771 if let (Directive::Price(orig), Directive::Price(rt)) = (&directive, &roundtrip) {
772 assert_eq!(orig.date, rt.date);
773 assert_eq!(orig.currency, rt.currency);
774 assert_eq!(orig.amount, rt.amount);
775 } else {
776 panic!("Expected Price directive");
777 }
778 }
779
780 #[test]
781 fn test_roundtrip_all_directive_types() {
782 let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
783
784 let directives = vec![
785 Directive::Open(Open {
786 date,
787 account: "Assets:Test".into(),
788 currencies: vec![],
789 booking: None,
790 meta: HashMap::new(),
791 }),
792 Directive::Close(Close {
793 date,
794 account: "Assets:Test".into(),
795 meta: HashMap::new(),
796 }),
797 Directive::Commodity(Commodity {
798 date,
799 currency: "TEST".into(),
800 meta: HashMap::new(),
801 }),
802 Directive::Pad(Pad {
803 date,
804 account: "Assets:Checking".into(),
805 source_account: "Equity:Opening".into(),
806 meta: HashMap::new(),
807 }),
808 Directive::Event(Event {
809 date,
810 event_type: "location".to_string(),
811 value: "Home".to_string(),
812 meta: HashMap::new(),
813 }),
814 Directive::Note(Note {
815 date,
816 account: "Assets:Test".into(),
817 comment: "Test note".to_string(),
818 meta: HashMap::new(),
819 }),
820 Directive::Document(Document {
821 date,
822 account: "Assets:Test".into(),
823 path: "/path/to/doc.pdf".to_string(),
824 tags: vec![],
825 links: vec![],
826 meta: HashMap::new(),
827 }),
828 Directive::Query(Query {
829 date,
830 name: "test_query".to_string(),
831 query: "SELECT * FROM transactions".to_string(),
832 meta: HashMap::new(),
833 }),
834 Directive::Custom(Custom {
835 date,
836 custom_type: "budget".to_string(),
837 values: vec![MetaValue::String("monthly".to_string())],
838 meta: HashMap::new(),
839 }),
840 ];
841
842 let wrappers = directives_to_wrappers(&directives);
843 let roundtrip = wrappers_to_directives(&wrappers).unwrap();
844
845 assert_eq!(directives.len(), roundtrip.len());
846 }
847}