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