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_roundtrip_transaction() {
235        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
236        let txn = Transaction {
237            date,
238            flag: '*',
239            payee: Some("Grocery Store".into()),
240            narration: "Weekly groceries".into(),
241            tags: vec!["food".into()],
242            links: vec!["grocery-2024".into()],
243            meta: Metadata::default(),
244            postings: vec![
245                rustledger_core::Spanned::synthesized(Posting {
246                    account: "Expenses:Food".into(),
247                    units: Some(IncompleteAmount::Complete(Amount::new(dec("50.00"), "USD"))),
248                    cost: None,
249                    price: None,
250                    flag: None,
251                    meta: Metadata::default(),
252                    comments: Vec::new(),
253                    trailing_comments: Vec::new(),
254                }),
255                rustledger_core::Spanned::synthesized(Posting {
256                    account: "Assets:Checking".into(),
257                    units: None,
258                    cost: None,
259                    price: None,
260                    flag: None,
261                    meta: Metadata::default(),
262                    comments: Vec::new(),
263                    trailing_comments: Vec::new(),
264                }),
265            ],
266            trailing_comments: Vec::new(),
267        };
268
269        let directive = Directive::Transaction(txn);
270        let wrapper = directive_to_wrapper(&directive);
271        let roundtrip = wrapper_to_directive(&wrapper).unwrap();
272
273        if let (Directive::Transaction(orig), Directive::Transaction(rt)) = (&directive, &roundtrip)
274        {
275            assert_eq!(orig.date, rt.date);
276            assert_eq!(orig.flag, rt.flag);
277            assert_eq!(orig.payee, rt.payee);
278            assert_eq!(orig.narration, rt.narration);
279            assert_eq!(orig.tags, rt.tags);
280            assert_eq!(orig.links, rt.links);
281            assert_eq!(orig.postings.len(), rt.postings.len());
282        } else {
283            panic!("Expected Transaction directive");
284        }
285    }
286
287    #[test]
288    fn test_roundtrip_balance() {
289        let date = rustledger_core::naive_date(2024, 1, 1).unwrap();
290        let balance = Balance {
291            date,
292            account: "Assets:Checking".into(),
293            amount: Amount::new(dec("1000.00"), "USD"),
294            tolerance: Some(dec("0.01")),
295            meta: Metadata::default(),
296        };
297
298        let directive = Directive::Balance(balance);
299        let wrapper = directive_to_wrapper(&directive);
300        let roundtrip = wrapper_to_directive(&wrapper).unwrap();
301
302        if let (Directive::Balance(orig), Directive::Balance(rt)) = (&directive, &roundtrip) {
303            assert_eq!(orig.date, rt.date);
304            assert_eq!(orig.account, rt.account);
305            assert_eq!(orig.amount, rt.amount);
306            assert_eq!(orig.tolerance, rt.tolerance);
307        } else {
308            panic!("Expected Balance directive");
309        }
310    }
311
312    #[test]
313    fn test_roundtrip_open() {
314        let date = rustledger_core::naive_date(2024, 1, 1).unwrap();
315        let open = Open {
316            date,
317            account: "Assets:Checking".into(),
318            currencies: vec!["USD".into(), "EUR".into()],
319            booking: Some("FIFO".to_string()),
320            meta: Metadata::default(),
321        };
322
323        let directive = Directive::Open(open);
324        let wrapper = directive_to_wrapper(&directive);
325        let roundtrip = wrapper_to_directive(&wrapper).unwrap();
326
327        if let (Directive::Open(orig), Directive::Open(rt)) = (&directive, &roundtrip) {
328            assert_eq!(orig.date, rt.date);
329            assert_eq!(orig.account, rt.account);
330            assert_eq!(orig.currencies, rt.currencies);
331            assert_eq!(orig.booking, rt.booking);
332        } else {
333            panic!("Expected Open directive");
334        }
335    }
336
337    #[test]
338    fn test_roundtrip_price() {
339        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
340        let price = Price {
341            date,
342            currency: "AAPL".into(),
343            amount: Amount::new(dec("185.50"), "USD"),
344            meta: Metadata::default(),
345        };
346
347        let directive = Directive::Price(price);
348        let wrapper = directive_to_wrapper(&directive);
349        let roundtrip = wrapper_to_directive(&wrapper).unwrap();
350
351        if let (Directive::Price(orig), Directive::Price(rt)) = (&directive, &roundtrip) {
352            assert_eq!(orig.date, rt.date);
353            assert_eq!(orig.currency, rt.currency);
354            assert_eq!(orig.amount, rt.amount);
355        } else {
356            panic!("Expected Price directive");
357        }
358    }
359
360    /// Regression for #1214: Document directives must carry tags
361    /// and links through both legs of the plugin round-trip. Pre-fix
362    /// both `document_to_data` (way out) and `data_to_document` (way
363    /// back in) dropped these fields to empty vectors, meaning any
364    /// document passing through a plugin lost its tags/links.
365    #[test]
366    fn test_roundtrip_document_tags_and_links_1214() {
367        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
368        let doc = Document {
369            date,
370            account: "Assets:Bank".into(),
371            path: "statements/2024-01.pdf".to_string(),
372            tags: vec!["statement".into(), "bank".into()],
373            links: vec!["inv-2024-01".into()],
374            meta: Metadata::default(),
375        };
376
377        let directive = Directive::Document(doc);
378        let wrapper = directive_to_wrapper(&directive);
379        let roundtrip = wrapper_to_directive(&wrapper).unwrap();
380
381        if let (Directive::Document(orig), Directive::Document(rt)) = (&directive, &roundtrip) {
382            assert_eq!(orig.date, rt.date);
383            assert_eq!(orig.account, rt.account);
384            assert_eq!(orig.path, rt.path);
385            assert_eq!(
386                orig.tags, rt.tags,
387                "Document.tags must survive the plugin round-trip",
388            );
389            assert_eq!(
390                orig.links, rt.links,
391                "Document.links must survive the plugin round-trip",
392            );
393        } else {
394            panic!("Expected Document directive");
395        }
396    }
397
398    #[test]
399    fn test_roundtrip_all_directive_types() {
400        let date = rustledger_core::naive_date(2024, 1, 1).unwrap();
401
402        let directives = vec![
403            Directive::Open(Open {
404                date,
405                account: "Assets:Test".into(),
406                currencies: vec![],
407                booking: None,
408                meta: Metadata::default(),
409            }),
410            Directive::Close(Close {
411                date,
412                account: "Assets:Test".into(),
413                meta: Metadata::default(),
414            }),
415            Directive::Commodity(Commodity {
416                date,
417                currency: "TEST".into(),
418                meta: Metadata::default(),
419            }),
420            Directive::Pad(Pad {
421                date,
422                account: "Assets:Checking".into(),
423                source_account: "Equity:Opening".into(),
424                meta: Metadata::default(),
425            }),
426            Directive::Event(Event {
427                date,
428                event_type: "location".to_string(),
429                value: "Home".to_string(),
430                meta: Metadata::default(),
431            }),
432            Directive::Note(Note {
433                date,
434                account: "Assets:Test".into(),
435                comment: "Test note".to_string(),
436                meta: Metadata::default(),
437            }),
438            Directive::Document(Document {
439                date,
440                account: "Assets:Test".into(),
441                path: "/path/to/doc.pdf".to_string(),
442                tags: vec![],
443                links: vec![],
444                meta: Metadata::default(),
445            }),
446            Directive::Query(Query {
447                date,
448                name: "test_query".to_string(),
449                query: "SELECT * FROM transactions".to_string(),
450                meta: Metadata::default(),
451            }),
452            Directive::Custom(Custom {
453                date,
454                custom_type: "budget".to_string(),
455                values: vec![MetaValue::String("monthly".to_string())],
456                meta: Metadata::default(),
457            }),
458        ];
459
460        let wrappers = directives_to_wrappers(&directives);
461        let roundtrip = wrappers_to_directives(&wrappers).unwrap();
462
463        assert_eq!(directives.len(), roundtrip.len());
464    }
465}