1mod from_wrapper;
4mod to_wrapper;
5
6use rustledger_core::{Directive, NaiveDate};
7
8use crate::types::{DirectiveData, DirectiveWrapper};
9
10use 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#[derive(Debug, Clone, thiserror::Error)]
24pub enum ConversionError {
25 #[error("invalid date format: {0}")]
27 InvalidDate(String),
28 #[error("invalid number format: {0}")]
32 InvalidNumber(String),
33 #[error("invalid flag: {0}")]
35 InvalidFlag(String),
36 #[error("cost spec invariant violated: {0}")]
45 BookedCostInvariantViolated(#[from] rustledger_core::BookedCostInvariantError),
46 #[error(
56 "PerUnitFromTotal cost spec requires units on the posting (got per_unit {per_unit}, total {total}, no units)"
57 )]
58 PerUnitFromTotalMissingUnits {
59 per_unit: rustledger_core::Decimal,
61 total: rustledger_core::Decimal,
63 },
64 #[error("source span overflow: {0}")]
72 SpanOverflow(String),
73}
74
75pub 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
90pub 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
183pub fn directives_to_wrappers(directives: &[Directive]) -> Vec<DirectiveWrapper> {
185 directives.iter().map(directive_to_wrapper).collect()
186}
187
188pub 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
213pub 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 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 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 #[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}