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. Use for parse failures only — for a pair
29    /// of valid numbers that fail a cross-field invariant, see
30    /// [`Self::BookedCostInvariantViolated`].
31    #[error("invalid number format: {0}")]
32    InvalidNumber(String),
33    /// Invalid flag format.
34    #[error("invalid flag: {0}")]
35    InvalidFlag(String),
36    /// A `PerUnitFromTotal` cost spec carries a (`per_unit`, `total`)
37    /// pair that doesn't agree with the posting's units. Returned by
38    /// the plugin egress and FFI input bridges when a plugin author
39    /// or JSON client supplies the post-booking shape with
40    /// inconsistent values — e.g. mutates `per_unit` without updating
41    /// `total`. The inner [`rustledger_core::BookedCostInvariantError`]
42    /// carries the supplied values, the derived total, and the
43    /// tolerance for plugin-author diagnostics.
44    #[error("cost spec invariant violated: {0}")]
45    BookedCostInvariantViolated(#[from] rustledger_core::BookedCostInvariantError),
46    /// A plugin emitted a `PerUnitFromTotal` cost spec without
47    /// supplying the posting's units. The post-booking shape is
48    /// undefined without units (`per_unit = total / |units|` requires
49    /// them) — a plugin emitting it untouched should keep the units
50    /// the host gave it; a plugin emitting a fresh post-booking spec
51    /// must include matching units. Distinct from
52    /// [`Self::BookedCostInvariantViolated`] because "missing" is a
53    /// different category from "inconsistent" — plugin authors fix
54    /// each with different actions (forgot vs miscalculated).
55    #[error(
56        "PerUnitFromTotal cost spec requires units on the posting (got per_unit {per_unit}, total {total}, no units)"
57    )]
58    PerUnitFromTotalMissingUnits {
59        /// The per-unit value the plugin supplied.
60        per_unit: rustledger_core::Decimal,
61        /// The total value the plugin supplied.
62        total: rustledger_core::Decimal,
63    },
64    /// A `SourceSpan` byte offset from the plugin wire format does not
65    /// fit in `usize` on the host. This is effectively impossible in
66    /// practice — `SourceSpan` is `u64` and the host is 64-bit on every
67    /// platform rustledger supports today, so the only way to surface
68    /// this variant is to run the host on a 32-bit target with source
69    /// files larger than 4 GiB. If you see it: please file a bug
70    /// report; the offset is almost certainly corrupt.
71    #[error("source span overflow: {0}")]
72    SpanOverflow(String),
73}
74
75/// Convert a directive to its serializable wrapper with source location.
76///
77/// The `filename` and `lineno` parameters are used for error reporting
78/// when the directive is later processed by plugins.
79pub fn directive_to_wrapper_with_location(
80    directive: &Directive,
81    filename: Option<String>,
82    lineno: Option<u32>,
83) -> DirectiveWrapper {
84    let mut wrapper = directive_to_wrapper(directive);
85    wrapper.filename = filename;
86    wrapper.lineno = lineno;
87    wrapper
88}
89
90/// Convert a directive to its serializable wrapper.
91///
92/// Note: This does not set filename/lineno - those must be set by the caller
93/// if source location tracking is needed.
94pub fn directive_to_wrapper(directive: &Directive) -> DirectiveWrapper {
95    match directive {
96        Directive::Transaction(txn) => DirectiveWrapper {
97            directive_type: "transaction".to_string(),
98            date: txn.date.to_string(),
99            filename: None,
100            lineno: None,
101            data: DirectiveData::Transaction(transaction_to_data(txn)),
102        },
103        Directive::Balance(bal) => DirectiveWrapper {
104            directive_type: "balance".to_string(),
105            date: bal.date.to_string(),
106            filename: None,
107            lineno: None,
108            data: DirectiveData::Balance(balance_to_data(bal)),
109        },
110        Directive::Open(open) => DirectiveWrapper {
111            directive_type: "open".to_string(),
112            date: open.date.to_string(),
113            filename: None,
114            lineno: None,
115            data: DirectiveData::Open(open_to_data(open)),
116        },
117        Directive::Close(close) => DirectiveWrapper {
118            directive_type: "close".to_string(),
119            date: close.date.to_string(),
120            filename: None,
121            lineno: None,
122            data: DirectiveData::Close(close_to_data(close)),
123        },
124        Directive::Commodity(comm) => DirectiveWrapper {
125            directive_type: "commodity".to_string(),
126            date: comm.date.to_string(),
127            filename: None,
128            lineno: None,
129            data: DirectiveData::Commodity(commodity_to_data(comm)),
130        },
131        Directive::Pad(pad) => DirectiveWrapper {
132            directive_type: "pad".to_string(),
133            date: pad.date.to_string(),
134            filename: None,
135            lineno: None,
136            data: DirectiveData::Pad(pad_to_data(pad)),
137        },
138        Directive::Event(event) => DirectiveWrapper {
139            directive_type: "event".to_string(),
140            date: event.date.to_string(),
141            filename: None,
142            lineno: None,
143            data: DirectiveData::Event(event_to_data(event)),
144        },
145        Directive::Note(note) => DirectiveWrapper {
146            directive_type: "note".to_string(),
147            date: note.date.to_string(),
148            filename: None,
149            lineno: None,
150            data: DirectiveData::Note(note_to_data(note)),
151        },
152        Directive::Document(doc) => DirectiveWrapper {
153            directive_type: "document".to_string(),
154            date: doc.date.to_string(),
155            filename: None,
156            lineno: None,
157            data: DirectiveData::Document(document_to_data(doc)),
158        },
159        Directive::Price(price) => DirectiveWrapper {
160            directive_type: "price".to_string(),
161            date: price.date.to_string(),
162            filename: None,
163            lineno: None,
164            data: DirectiveData::Price(price_to_data(price)),
165        },
166        Directive::Query(query) => DirectiveWrapper {
167            directive_type: "query".to_string(),
168            date: query.date.to_string(),
169            filename: None,
170            lineno: None,
171            data: DirectiveData::Query(query_to_data(query)),
172        },
173        Directive::Custom(custom) => DirectiveWrapper {
174            directive_type: "custom".to_string(),
175            date: custom.date.to_string(),
176            filename: None,
177            lineno: None,
178            data: DirectiveData::Custom(custom_to_data(custom)),
179        },
180    }
181}
182
183/// Convert a list of directives to serializable wrappers.
184pub fn directives_to_wrappers(directives: &[Directive]) -> Vec<DirectiveWrapper> {
185    directives.iter().map(directive_to_wrapper).collect()
186}
187
188/// Convert a serializable wrapper back to a directive.
189pub fn wrapper_to_directive(wrapper: &DirectiveWrapper) -> Result<Directive, ConversionError> {
190    let date = wrapper
191        .date
192        .parse::<NaiveDate>()
193        .map_err(|_| ConversionError::InvalidDate(wrapper.date.clone()))?;
194
195    match &wrapper.data {
196        DirectiveData::Transaction(data) => {
197            Ok(Directive::Transaction(data_to_transaction(data, date)?))
198        }
199        DirectiveData::Balance(data) => Ok(Directive::Balance(data_to_balance(data, date)?)),
200        DirectiveData::Open(data) => Ok(Directive::Open(data_to_open(data, date))),
201        DirectiveData::Close(data) => Ok(Directive::Close(data_to_close(data, date))),
202        DirectiveData::Commodity(data) => Ok(Directive::Commodity(data_to_commodity(data, date))),
203        DirectiveData::Pad(data) => Ok(Directive::Pad(data_to_pad(data, date))),
204        DirectiveData::Event(data) => Ok(Directive::Event(data_to_event(data, date))),
205        DirectiveData::Note(data) => Ok(Directive::Note(data_to_note(data, date))),
206        DirectiveData::Document(data) => Ok(Directive::Document(data_to_document(data, date))),
207        DirectiveData::Price(data) => Ok(Directive::Price(data_to_price(data, date)?)),
208        DirectiveData::Query(data) => Ok(Directive::Query(data_to_query(data, date))),
209        DirectiveData::Custom(data) => Ok(Directive::Custom(data_to_custom(data, date))),
210    }
211}
212
213/// Convert a list of serializable wrappers back to directives.
214pub fn wrappers_to_directives(
215    wrappers: &[DirectiveWrapper],
216) -> Result<Vec<Directive>, ConversionError> {
217    wrappers.iter().map(wrapper_to_directive).collect()
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use rustledger_core::{
224        Amount, Balance, Close, Commodity, Custom, Decimal, Document, Event, IncompleteAmount,
225        MetaValue, Metadata, Note, Open, Pad, Posting, Price, Query, Transaction,
226    };
227    use std::str::FromStr;
228
229    fn dec(s: &str) -> Decimal {
230        Decimal::from_str(s).unwrap()
231    }
232
233    #[test]
234    fn test_multi_char_wire_flag_rejected_not_truncated() {
235        use rustledger_plugin_types::TransactionData;
236        let date = rustledger_core::naive_date(2024, 1, 1).unwrap();
237        let data = TransactionData {
238            flag: "txn".to_string(),
239            payee: None,
240            narration: "test".to_string(),
241            tags: vec![],
242            links: vec![],
243            metadata: vec![],
244            postings: vec![],
245        };
246        // A multi-char wire flag must be REJECTED, not silently truncated to
247        // its first char ("txn" -> 't').
248        let result = super::from_wrapper::data_to_transaction(&data, date);
249        assert!(
250            matches!(&result, Err(ConversionError::InvalidFlag(f)) if f == "txn"),
251            "multi-char flag must be rejected, got {result:?}"
252        );
253
254        // A single-char flag still converts.
255        let single = TransactionData {
256            flag: "x".to_string(),
257            ..data
258        };
259        let ok = super::from_wrapper::data_to_transaction(&single, date);
260        assert!(
261            matches!(ok, Ok(txn) if txn.flag == 'x'),
262            "single-char flag should convert"
263        );
264    }
265
266    #[test]
267    fn test_roundtrip_transaction() {
268        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
269        let txn = Transaction {
270            date,
271            flag: '*',
272            payee: Some("Grocery Store".into()),
273            narration: "Weekly groceries".into(),
274            tags: vec!["food".into()],
275            links: vec!["grocery-2024".into()],
276            meta: Metadata::default(),
277            postings: vec![
278                rustledger_core::Spanned::synthesized(Posting {
279                    account: "Expenses:Food".into(),
280                    units: Some(IncompleteAmount::Complete(Amount::new(dec("50.00"), "USD"))),
281                    cost: None,
282                    price: None,
283                    flag: None,
284                    meta: Metadata::default(),
285                    comments: Vec::new(),
286                    trailing_comments: Vec::new(),
287                }),
288                rustledger_core::Spanned::synthesized(Posting {
289                    account: "Assets:Checking".into(),
290                    units: None,
291                    cost: None,
292                    price: None,
293                    flag: None,
294                    meta: Metadata::default(),
295                    comments: Vec::new(),
296                    trailing_comments: Vec::new(),
297                }),
298            ],
299            trailing_comments: Vec::new(),
300        };
301
302        let directive = Directive::Transaction(txn);
303        let wrapper = directive_to_wrapper(&directive);
304        let roundtrip = wrapper_to_directive(&wrapper).unwrap();
305
306        if let (Directive::Transaction(orig), Directive::Transaction(rt)) = (&directive, &roundtrip)
307        {
308            assert_eq!(orig.date, rt.date);
309            assert_eq!(orig.flag, rt.flag);
310            assert_eq!(orig.payee, rt.payee);
311            assert_eq!(orig.narration, rt.narration);
312            assert_eq!(orig.tags, rt.tags);
313            assert_eq!(orig.links, rt.links);
314            assert_eq!(orig.postings.len(), rt.postings.len());
315        } else {
316            panic!("Expected Transaction directive");
317        }
318    }
319
320    #[test]
321    fn test_roundtrip_balance() {
322        let date = rustledger_core::naive_date(2024, 1, 1).unwrap();
323        let balance = Balance {
324            date,
325            account: "Assets:Checking".into(),
326            amount: Amount::new(dec("1000.00"), "USD"),
327            tolerance: Some(dec("0.01")),
328            meta: Metadata::default(),
329        };
330
331        let directive = Directive::Balance(balance);
332        let wrapper = directive_to_wrapper(&directive);
333        let roundtrip = wrapper_to_directive(&wrapper).unwrap();
334
335        if let (Directive::Balance(orig), Directive::Balance(rt)) = (&directive, &roundtrip) {
336            assert_eq!(orig.date, rt.date);
337            assert_eq!(orig.account, rt.account);
338            assert_eq!(orig.amount, rt.amount);
339            assert_eq!(orig.tolerance, rt.tolerance);
340        } else {
341            panic!("Expected Balance directive");
342        }
343    }
344
345    #[test]
346    fn test_roundtrip_open() {
347        let date = rustledger_core::naive_date(2024, 1, 1).unwrap();
348        let open = Open {
349            date,
350            account: "Assets:Checking".into(),
351            currencies: vec!["USD".into(), "EUR".into()],
352            booking: Some("FIFO".to_string()),
353            meta: Metadata::default(),
354        };
355
356        let directive = Directive::Open(open);
357        let wrapper = directive_to_wrapper(&directive);
358        let roundtrip = wrapper_to_directive(&wrapper).unwrap();
359
360        if let (Directive::Open(orig), Directive::Open(rt)) = (&directive, &roundtrip) {
361            assert_eq!(orig.date, rt.date);
362            assert_eq!(orig.account, rt.account);
363            assert_eq!(orig.currencies, rt.currencies);
364            assert_eq!(orig.booking, rt.booking);
365        } else {
366            panic!("Expected Open directive");
367        }
368    }
369
370    #[test]
371    fn test_roundtrip_price() {
372        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
373        let price = Price {
374            date,
375            currency: "AAPL".into(),
376            amount: Amount::new(dec("185.50"), "USD"),
377            meta: Metadata::default(),
378        };
379
380        let directive = Directive::Price(price);
381        let wrapper = directive_to_wrapper(&directive);
382        let roundtrip = wrapper_to_directive(&wrapper).unwrap();
383
384        if let (Directive::Price(orig), Directive::Price(rt)) = (&directive, &roundtrip) {
385            assert_eq!(orig.date, rt.date);
386            assert_eq!(orig.currency, rt.currency);
387            assert_eq!(orig.amount, rt.amount);
388        } else {
389            panic!("Expected Price directive");
390        }
391    }
392
393    /// Regression for #1214: Document directives must carry tags
394    /// and links through both legs of the plugin round-trip. Pre-fix
395    /// both `document_to_data` (way out) and `data_to_document` (way
396    /// back in) dropped these fields to empty vectors, meaning any
397    /// document passing through a plugin lost its tags/links.
398    #[test]
399    fn test_roundtrip_document_tags_and_links_1214() {
400        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
401        let doc = Document {
402            date,
403            account: "Assets:Bank".into(),
404            path: "statements/2024-01.pdf".to_string(),
405            tags: vec!["statement".into(), "bank".into()],
406            links: vec!["inv-2024-01".into()],
407            meta: Metadata::default(),
408        };
409
410        let directive = Directive::Document(doc);
411        let wrapper = directive_to_wrapper(&directive);
412        let roundtrip = wrapper_to_directive(&wrapper).unwrap();
413
414        if let (Directive::Document(orig), Directive::Document(rt)) = (&directive, &roundtrip) {
415            assert_eq!(orig.date, rt.date);
416            assert_eq!(orig.account, rt.account);
417            assert_eq!(orig.path, rt.path);
418            assert_eq!(
419                orig.tags, rt.tags,
420                "Document.tags must survive the plugin round-trip",
421            );
422            assert_eq!(
423                orig.links, rt.links,
424                "Document.links must survive the plugin round-trip",
425            );
426        } else {
427            panic!("Expected Document directive");
428        }
429    }
430
431    #[test]
432    fn test_roundtrip_all_directive_types() {
433        let date = rustledger_core::naive_date(2024, 1, 1).unwrap();
434
435        let directives = vec![
436            Directive::Open(Open {
437                date,
438                account: "Assets:Test".into(),
439                currencies: vec![],
440                booking: None,
441                meta: Metadata::default(),
442            }),
443            Directive::Close(Close {
444                date,
445                account: "Assets:Test".into(),
446                meta: Metadata::default(),
447            }),
448            Directive::Commodity(Commodity {
449                date,
450                currency: "TEST".into(),
451                meta: Metadata::default(),
452            }),
453            Directive::Pad(Pad {
454                date,
455                account: "Assets:Checking".into(),
456                source_account: "Equity:Opening".into(),
457                meta: Metadata::default(),
458            }),
459            Directive::Event(Event {
460                date,
461                event_type: "location".to_string(),
462                value: "Home".to_string(),
463                meta: Metadata::default(),
464            }),
465            Directive::Note(Note {
466                date,
467                account: "Assets:Test".into(),
468                comment: "Test note".to_string(),
469                meta: Metadata::default(),
470            }),
471            Directive::Document(Document {
472                date,
473                account: "Assets:Test".into(),
474                path: "/path/to/doc.pdf".to_string(),
475                tags: vec![],
476                links: vec![],
477                meta: Metadata::default(),
478            }),
479            Directive::Query(Query {
480                date,
481                name: "test_query".to_string(),
482                query: "SELECT * FROM transactions".to_string(),
483                meta: Metadata::default(),
484            }),
485            Directive::Custom(Custom {
486                date,
487                custom_type: "budget".to_string(),
488                values: vec![MetaValue::String("monthly".to_string())],
489                meta: Metadata::default(),
490            }),
491        ];
492
493        let wrappers = directives_to_wrappers(&directives);
494        let roundtrip = wrappers_to_directives(&wrappers).unwrap();
495
496        assert_eq!(directives.len(), roundtrip.len());
497    }
498}