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