rustledger_plugin/
convert.rs

1//! Conversion between core types and plugin serialization types.
2
3use 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
15/// Convert a directive to its serializable wrapper.
16pub 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(), // Empty number indicates interpolation needed
117            currency: currency.to_string(),
118        }),
119        IncompleteAmount::NumberOnly(number) => Some(AmountData {
120            number: number.to_string(),
121            currency: String::new(), // Empty currency indicates inference needed
122        }),
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
282/// Convert a list of directives to serializable wrappers.
283pub fn directives_to_wrappers(directives: &[Directive]) -> Vec<DirectiveWrapper> {
284    directives.iter().map(directive_to_wrapper).collect()
285}
286
287/// Error returned when converting a wrapper back to a directive fails.
288#[derive(Debug, Clone, thiserror::Error)]
289pub enum ConversionError {
290    /// Invalid date format.
291    #[error("invalid date format: {0}")]
292    InvalidDate(String),
293    /// Invalid number format.
294    #[error("invalid number format: {0}")]
295    InvalidNumber(String),
296    /// Invalid flag format.
297    #[error("invalid flag: {0}")]
298    InvalidFlag(String),
299    /// Unknown directive type.
300    #[error("unknown directive type: {0}")]
301    UnknownDirective(String),
302}
303
304/// Convert a serializable wrapper back to a directive.
305pub 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        // Incomplete price
463        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        // Empty price
483        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
641/// Convert a list of serializable wrappers back to directives.
642pub 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}