Skip to main content

rustledger_plugin/convert/
mod.rs

1//! Conversion between core types and plugin serialization types.
2
3mod from_wrapper;
4mod to_wrapper;
5
6use rustledger_core::{Directive, NaiveDate};
7
8use crate::types::{DirectiveData, DirectiveWrapper};
9
10// Re-export conversion functions
11use from_wrapper::{
12    data_to_balance, data_to_close, data_to_commodity, data_to_custom, data_to_document,
13    data_to_event, data_to_note, data_to_open, data_to_pad, data_to_price, data_to_query,
14    data_to_transaction,
15};
16use to_wrapper::{
17    balance_to_data, close_to_data, commodity_to_data, custom_to_data, document_to_data,
18    event_to_data, note_to_data, open_to_data, pad_to_data, price_to_data, query_to_data,
19    transaction_to_data,
20};
21
22/// Error returned when converting a wrapper back to a directive fails.
23#[derive(Debug, Clone, thiserror::Error)]
24pub enum ConversionError {
25    /// Invalid date format.
26    #[error("invalid date format: {0}")]
27    InvalidDate(String),
28    /// Invalid number format.
29    #[error("invalid number format: {0}")]
30    InvalidNumber(String),
31    /// Invalid flag format.
32    #[error("invalid flag: {0}")]
33    InvalidFlag(String),
34    /// Unknown directive type.
35    #[error("unknown directive type: {0}")]
36    UnknownDirective(String),
37}
38
39/// Convert a directive to its serializable wrapper.
40///
41/// Note: This does not set filename/lineno - those must be set by the caller
42/// if source location tracking is needed.
43pub fn directive_to_wrapper(directive: &Directive) -> DirectiveWrapper {
44    match directive {
45        Directive::Transaction(txn) => DirectiveWrapper {
46            directive_type: "transaction".to_string(),
47            date: txn.date.to_string(),
48            filename: None,
49            lineno: None,
50            data: DirectiveData::Transaction(transaction_to_data(txn)),
51        },
52        Directive::Balance(bal) => DirectiveWrapper {
53            directive_type: "balance".to_string(),
54            date: bal.date.to_string(),
55            filename: None,
56            lineno: None,
57            data: DirectiveData::Balance(balance_to_data(bal)),
58        },
59        Directive::Open(open) => DirectiveWrapper {
60            directive_type: "open".to_string(),
61            date: open.date.to_string(),
62            filename: None,
63            lineno: None,
64            data: DirectiveData::Open(open_to_data(open)),
65        },
66        Directive::Close(close) => DirectiveWrapper {
67            directive_type: "close".to_string(),
68            date: close.date.to_string(),
69            filename: None,
70            lineno: None,
71            data: DirectiveData::Close(close_to_data(close)),
72        },
73        Directive::Commodity(comm) => DirectiveWrapper {
74            directive_type: "commodity".to_string(),
75            date: comm.date.to_string(),
76            filename: None,
77            lineno: None,
78            data: DirectiveData::Commodity(commodity_to_data(comm)),
79        },
80        Directive::Pad(pad) => DirectiveWrapper {
81            directive_type: "pad".to_string(),
82            date: pad.date.to_string(),
83            filename: None,
84            lineno: None,
85            data: DirectiveData::Pad(pad_to_data(pad)),
86        },
87        Directive::Event(event) => DirectiveWrapper {
88            directive_type: "event".to_string(),
89            date: event.date.to_string(),
90            filename: None,
91            lineno: None,
92            data: DirectiveData::Event(event_to_data(event)),
93        },
94        Directive::Note(note) => DirectiveWrapper {
95            directive_type: "note".to_string(),
96            date: note.date.to_string(),
97            filename: None,
98            lineno: None,
99            data: DirectiveData::Note(note_to_data(note)),
100        },
101        Directive::Document(doc) => DirectiveWrapper {
102            directive_type: "document".to_string(),
103            date: doc.date.to_string(),
104            filename: None,
105            lineno: None,
106            data: DirectiveData::Document(document_to_data(doc)),
107        },
108        Directive::Price(price) => DirectiveWrapper {
109            directive_type: "price".to_string(),
110            date: price.date.to_string(),
111            filename: None,
112            lineno: None,
113            data: DirectiveData::Price(price_to_data(price)),
114        },
115        Directive::Query(query) => DirectiveWrapper {
116            directive_type: "query".to_string(),
117            date: query.date.to_string(),
118            filename: None,
119            lineno: None,
120            data: DirectiveData::Query(query_to_data(query)),
121        },
122        Directive::Custom(custom) => DirectiveWrapper {
123            directive_type: "custom".to_string(),
124            date: custom.date.to_string(),
125            filename: None,
126            lineno: None,
127            data: DirectiveData::Custom(custom_to_data(custom)),
128        },
129    }
130}
131
132/// Convert a list of directives to serializable wrappers.
133pub fn directives_to_wrappers(directives: &[Directive]) -> Vec<DirectiveWrapper> {
134    directives.iter().map(directive_to_wrapper).collect()
135}
136
137/// Convert a serializable wrapper back to a directive.
138pub fn wrapper_to_directive(wrapper: &DirectiveWrapper) -> Result<Directive, ConversionError> {
139    let date = NaiveDate::parse_from_str(&wrapper.date, "%Y-%m-%d")
140        .map_err(|_| ConversionError::InvalidDate(wrapper.date.clone()))?;
141
142    match &wrapper.data {
143        DirectiveData::Transaction(data) => {
144            Ok(Directive::Transaction(data_to_transaction(data, date)?))
145        }
146        DirectiveData::Balance(data) => Ok(Directive::Balance(data_to_balance(data, date)?)),
147        DirectiveData::Open(data) => Ok(Directive::Open(data_to_open(data, date))),
148        DirectiveData::Close(data) => Ok(Directive::Close(data_to_close(data, date))),
149        DirectiveData::Commodity(data) => Ok(Directive::Commodity(data_to_commodity(data, date))),
150        DirectiveData::Pad(data) => Ok(Directive::Pad(data_to_pad(data, date))),
151        DirectiveData::Event(data) => Ok(Directive::Event(data_to_event(data, date))),
152        DirectiveData::Note(data) => Ok(Directive::Note(data_to_note(data, date))),
153        DirectiveData::Document(data) => Ok(Directive::Document(data_to_document(data, date))),
154        DirectiveData::Price(data) => Ok(Directive::Price(data_to_price(data, date)?)),
155        DirectiveData::Query(data) => Ok(Directive::Query(data_to_query(data, date))),
156        DirectiveData::Custom(data) => Ok(Directive::Custom(data_to_custom(data, date))),
157    }
158}
159
160/// Convert a list of serializable wrappers back to directives.
161pub fn wrappers_to_directives(
162    wrappers: &[DirectiveWrapper],
163) -> Result<Vec<Directive>, ConversionError> {
164    wrappers.iter().map(wrapper_to_directive).collect()
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use rustledger_core::{
171        Amount, Balance, Close, Commodity, Custom, Decimal, Document, Event, IncompleteAmount,
172        MetaValue, Metadata, Note, Open, Pad, Posting, Price, Query, Transaction,
173    };
174    use std::str::FromStr;
175
176    fn dec(s: &str) -> Decimal {
177        Decimal::from_str(s).unwrap()
178    }
179
180    #[test]
181    fn test_roundtrip_transaction() {
182        let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
183        let txn = Transaction {
184            date,
185            flag: '*',
186            payee: Some("Grocery Store".into()),
187            narration: "Weekly groceries".into(),
188            tags: vec!["food".into()],
189            links: vec!["grocery-2024".into()],
190            meta: Metadata::default(),
191            postings: vec![
192                Posting {
193                    account: "Expenses:Food".into(),
194                    units: Some(IncompleteAmount::Complete(Amount::new(dec("50.00"), "USD"))),
195                    cost: None,
196                    price: None,
197                    flag: None,
198                    meta: Metadata::default(),
199                },
200                Posting {
201                    account: "Assets:Checking".into(),
202                    units: None,
203                    cost: None,
204                    price: None,
205                    flag: None,
206                    meta: Metadata::default(),
207                },
208            ],
209        };
210
211        let directive = Directive::Transaction(txn);
212        let wrapper = directive_to_wrapper(&directive);
213        let roundtrip = wrapper_to_directive(&wrapper).unwrap();
214
215        if let (Directive::Transaction(orig), Directive::Transaction(rt)) = (&directive, &roundtrip)
216        {
217            assert_eq!(orig.date, rt.date);
218            assert_eq!(orig.flag, rt.flag);
219            assert_eq!(orig.payee, rt.payee);
220            assert_eq!(orig.narration, rt.narration);
221            assert_eq!(orig.tags, rt.tags);
222            assert_eq!(orig.links, rt.links);
223            assert_eq!(orig.postings.len(), rt.postings.len());
224        } else {
225            panic!("Expected Transaction directive");
226        }
227    }
228
229    #[test]
230    fn test_roundtrip_balance() {
231        let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
232        let balance = Balance {
233            date,
234            account: "Assets:Checking".into(),
235            amount: Amount::new(dec("1000.00"), "USD"),
236            tolerance: Some(dec("0.01")),
237            meta: Metadata::default(),
238        };
239
240        let directive = Directive::Balance(balance);
241        let wrapper = directive_to_wrapper(&directive);
242        let roundtrip = wrapper_to_directive(&wrapper).unwrap();
243
244        if let (Directive::Balance(orig), Directive::Balance(rt)) = (&directive, &roundtrip) {
245            assert_eq!(orig.date, rt.date);
246            assert_eq!(orig.account, rt.account);
247            assert_eq!(orig.amount, rt.amount);
248            assert_eq!(orig.tolerance, rt.tolerance);
249        } else {
250            panic!("Expected Balance directive");
251        }
252    }
253
254    #[test]
255    fn test_roundtrip_open() {
256        let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
257        let open = Open {
258            date,
259            account: "Assets:Checking".into(),
260            currencies: vec!["USD".into(), "EUR".into()],
261            booking: Some("FIFO".to_string()),
262            meta: Metadata::default(),
263        };
264
265        let directive = Directive::Open(open);
266        let wrapper = directive_to_wrapper(&directive);
267        let roundtrip = wrapper_to_directive(&wrapper).unwrap();
268
269        if let (Directive::Open(orig), Directive::Open(rt)) = (&directive, &roundtrip) {
270            assert_eq!(orig.date, rt.date);
271            assert_eq!(orig.account, rt.account);
272            assert_eq!(orig.currencies, rt.currencies);
273            assert_eq!(orig.booking, rt.booking);
274        } else {
275            panic!("Expected Open directive");
276        }
277    }
278
279    #[test]
280    fn test_roundtrip_price() {
281        let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
282        let price = Price {
283            date,
284            currency: "AAPL".into(),
285            amount: Amount::new(dec("185.50"), "USD"),
286            meta: Metadata::default(),
287        };
288
289        let directive = Directive::Price(price);
290        let wrapper = directive_to_wrapper(&directive);
291        let roundtrip = wrapper_to_directive(&wrapper).unwrap();
292
293        if let (Directive::Price(orig), Directive::Price(rt)) = (&directive, &roundtrip) {
294            assert_eq!(orig.date, rt.date);
295            assert_eq!(orig.currency, rt.currency);
296            assert_eq!(orig.amount, rt.amount);
297        } else {
298            panic!("Expected Price directive");
299        }
300    }
301
302    #[test]
303    fn test_roundtrip_all_directive_types() {
304        let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
305
306        let directives = vec![
307            Directive::Open(Open {
308                date,
309                account: "Assets:Test".into(),
310                currencies: vec![],
311                booking: None,
312                meta: Metadata::default(),
313            }),
314            Directive::Close(Close {
315                date,
316                account: "Assets:Test".into(),
317                meta: Metadata::default(),
318            }),
319            Directive::Commodity(Commodity {
320                date,
321                currency: "TEST".into(),
322                meta: Metadata::default(),
323            }),
324            Directive::Pad(Pad {
325                date,
326                account: "Assets:Checking".into(),
327                source_account: "Equity:Opening".into(),
328                meta: Metadata::default(),
329            }),
330            Directive::Event(Event {
331                date,
332                event_type: "location".to_string(),
333                value: "Home".to_string(),
334                meta: Metadata::default(),
335            }),
336            Directive::Note(Note {
337                date,
338                account: "Assets:Test".into(),
339                comment: "Test note".to_string(),
340                meta: Metadata::default(),
341            }),
342            Directive::Document(Document {
343                date,
344                account: "Assets:Test".into(),
345                path: "/path/to/doc.pdf".to_string(),
346                tags: vec![],
347                links: vec![],
348                meta: Metadata::default(),
349            }),
350            Directive::Query(Query {
351                date,
352                name: "test_query".to_string(),
353                query: "SELECT * FROM transactions".to_string(),
354                meta: Metadata::default(),
355            }),
356            Directive::Custom(Custom {
357                date,
358                custom_type: "budget".to_string(),
359                values: vec![MetaValue::String("monthly".to_string())],
360                meta: Metadata::default(),
361            }),
362        ];
363
364        let wrappers = directives_to_wrappers(&directives);
365        let roundtrip = wrappers_to_directives(&wrappers).unwrap();
366
367        assert_eq!(directives.len(), roundtrip.len());
368    }
369}