1use crate::Options;
20use rust_decimal::Decimal;
21use rustledger_core::Directive;
22use rustledger_core::intern::StringInterner;
23use rustledger_parser::Spanned;
24use sha2::{Digest, Sha256};
25use std::fs;
26use std::io::{Read, Write};
27use std::path::{Path, PathBuf};
28use std::str::FromStr;
29
30#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
32pub struct CachedPlugin {
33 pub name: String,
35 pub config: Option<String>,
37 pub force_python: bool,
39}
40
41#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
46#[allow(missing_docs)]
47pub struct CachedOptions {
48 pub title: Option<String>,
49 pub filename: Option<String>,
50 pub operating_currency: Vec<String>,
51 pub name_assets: String,
52 pub name_liabilities: String,
53 pub name_equity: String,
54 pub name_income: String,
55 pub name_expenses: String,
56 pub account_rounding: Option<String>,
57 pub account_previous_balances: String,
58 pub account_previous_earnings: String,
59 pub account_previous_conversions: String,
60 pub account_current_earnings: String,
61 pub account_current_conversions: Option<String>,
62 pub account_unrealized_gains: Option<String>,
63 pub conversion_currency: Option<String>,
64 pub inferred_tolerance_default: Vec<(String, String)>,
66 pub inferred_tolerance_multiplier: String,
67 pub infer_tolerance_from_cost: bool,
68 pub use_legacy_fixed_tolerances: bool,
69 pub experiment_explicit_tolerances: bool,
70 pub booking_method: String,
71 pub render_commas: bool,
72 pub allow_pipe_separator: bool,
73 pub long_string_maxlines: u32,
74 pub documents: Vec<String>,
75 pub custom: Vec<(String, String)>,
76}
77
78impl From<&Options> for CachedOptions {
79 fn from(opts: &Options) -> Self {
80 Self {
81 title: opts.title.clone(),
82 filename: opts.filename.clone(),
83 operating_currency: opts.operating_currency.clone(),
84 name_assets: opts.name_assets.clone(),
85 name_liabilities: opts.name_liabilities.clone(),
86 name_equity: opts.name_equity.clone(),
87 name_income: opts.name_income.clone(),
88 name_expenses: opts.name_expenses.clone(),
89 account_rounding: opts.account_rounding.clone(),
90 account_previous_balances: opts.account_previous_balances.clone(),
91 account_previous_earnings: opts.account_previous_earnings.clone(),
92 account_previous_conversions: opts.account_previous_conversions.clone(),
93 account_current_earnings: opts.account_current_earnings.clone(),
94 account_current_conversions: opts.account_current_conversions.clone(),
95 account_unrealized_gains: opts.account_unrealized_gains.clone(),
96 conversion_currency: opts.conversion_currency.clone(),
97 inferred_tolerance_default: opts
98 .inferred_tolerance_default
99 .iter()
100 .map(|(k, v)| (k.clone(), v.to_string()))
101 .collect(),
102 inferred_tolerance_multiplier: opts.inferred_tolerance_multiplier.to_string(),
103 infer_tolerance_from_cost: opts.infer_tolerance_from_cost,
104 use_legacy_fixed_tolerances: opts.use_legacy_fixed_tolerances,
105 experiment_explicit_tolerances: opts.experiment_explicit_tolerances,
106 booking_method: opts.booking_method.clone(),
107 render_commas: opts.render_commas,
108 allow_pipe_separator: opts.allow_pipe_separator,
109 long_string_maxlines: opts.long_string_maxlines,
110 documents: opts.documents.clone(),
111 custom: opts
112 .custom
113 .iter()
114 .map(|(k, v)| (k.clone(), v.clone()))
115 .collect(),
116 }
117 }
118}
119
120impl From<CachedOptions> for Options {
121 fn from(cached: CachedOptions) -> Self {
122 let mut opts = Self::new();
123 opts.title = cached.title;
124 opts.filename = cached.filename;
125 opts.operating_currency = cached.operating_currency;
126 opts.name_assets = cached.name_assets;
127 opts.name_liabilities = cached.name_liabilities;
128 opts.name_equity = cached.name_equity;
129 opts.name_income = cached.name_income;
130 opts.name_expenses = cached.name_expenses;
131 opts.account_rounding = cached.account_rounding;
132 opts.account_previous_balances = cached.account_previous_balances;
133 opts.account_previous_earnings = cached.account_previous_earnings;
134 opts.account_previous_conversions = cached.account_previous_conversions;
135 opts.account_current_earnings = cached.account_current_earnings;
136 opts.account_current_conversions = cached.account_current_conversions;
137 opts.account_unrealized_gains = cached.account_unrealized_gains;
138 opts.conversion_currency = cached.conversion_currency;
139 opts.inferred_tolerance_default = cached
140 .inferred_tolerance_default
141 .into_iter()
142 .filter_map(|(k, v)| Decimal::from_str(&v).ok().map(|d| (k, d)))
143 .collect();
144 opts.inferred_tolerance_multiplier =
145 Decimal::from_str(&cached.inferred_tolerance_multiplier)
146 .unwrap_or_else(|_| Decimal::new(5, 1));
147 opts.infer_tolerance_from_cost = cached.infer_tolerance_from_cost;
148 opts.use_legacy_fixed_tolerances = cached.use_legacy_fixed_tolerances;
149 opts.experiment_explicit_tolerances = cached.experiment_explicit_tolerances;
150 opts.booking_method = cached.booking_method;
151 opts.render_commas = cached.render_commas;
152 opts.allow_pipe_separator = cached.allow_pipe_separator;
153 opts.long_string_maxlines = cached.long_string_maxlines;
154 opts.documents = cached.documents;
155 opts.custom = cached.custom.into_iter().collect();
156 opts
157 }
158}
159
160#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
162pub struct CacheEntry {
163 pub directives: Vec<Spanned<Directive>>,
165 pub options: CachedOptions,
167 pub plugins: Vec<CachedPlugin>,
169 pub files: Vec<String>,
171}
172
173impl CacheEntry {
174 pub fn file_paths(&self) -> Vec<PathBuf> {
176 self.files.iter().map(PathBuf::from).collect()
177 }
178}
179
180const CACHE_MAGIC: &[u8; 8] = b"RLEDGER\0";
182
183const CACHE_VERSION: u32 = 3;
188
189#[derive(Debug, Clone)]
191struct CacheHeader {
192 magic: [u8; 8],
194 version: u32,
196 hash: [u8; 32],
198 data_len: u64,
200}
201
202impl CacheHeader {
203 const SIZE: usize = 8 + 4 + 32 + 8;
204
205 fn to_bytes(&self) -> [u8; Self::SIZE] {
206 let mut buf = [0u8; Self::SIZE];
207 buf[0..8].copy_from_slice(&self.magic);
208 buf[8..12].copy_from_slice(&self.version.to_le_bytes());
209 buf[12..44].copy_from_slice(&self.hash);
210 buf[44..52].copy_from_slice(&self.data_len.to_le_bytes());
211 buf
212 }
213
214 fn from_bytes(bytes: &[u8]) -> Option<Self> {
215 if bytes.len() < Self::SIZE {
216 return None;
217 }
218
219 let mut magic = [0u8; 8];
220 magic.copy_from_slice(&bytes[0..8]);
221
222 let version = u32::from_le_bytes(bytes[8..12].try_into().ok()?);
223
224 let mut hash = [0u8; 32];
225 hash.copy_from_slice(&bytes[12..44]);
226
227 let data_len = u64::from_le_bytes(bytes[44..52].try_into().ok()?);
228
229 Some(Self {
230 magic,
231 version,
232 hash,
233 data_len,
234 })
235 }
236}
237
238fn compute_hash(files: &[&Path]) -> [u8; 32] {
244 let mut hasher = Sha256::new();
245
246 for file in files {
247 hasher.update(file.to_string_lossy().as_bytes());
249
250 if let Ok(metadata) = fs::metadata(file) {
252 if let Ok(mtime) = metadata.modified()
253 && let Ok(duration) = mtime.duration_since(std::time::UNIX_EPOCH)
254 {
255 hasher.update(duration.as_secs().to_le_bytes());
256 hasher.update(duration.subsec_nanos().to_le_bytes());
257 }
258 hasher.update(metadata.len().to_le_bytes());
260 }
261 }
262
263 hasher.finalize().into()
264}
265
266fn cache_path(source: &Path) -> std::path::PathBuf {
268 let mut path = source.to_path_buf();
269 let name = path.file_name().map_or_else(
270 || "ledger.cache".to_string(),
271 |n| format!("{}.cache", n.to_string_lossy()),
272 );
273 path.set_file_name(name);
274 path
275}
276
277pub fn load_cache_entry(main_file: &Path) -> Option<CacheEntry> {
282 let cache_file = cache_path(main_file);
283 let mut file = fs::File::open(&cache_file).ok()?;
284
285 let mut header_bytes = [0u8; CacheHeader::SIZE];
287 file.read_exact(&mut header_bytes).ok()?;
288 let header = CacheHeader::from_bytes(&header_bytes)?;
289
290 if header.magic != *CACHE_MAGIC {
292 return None;
293 }
294 if header.version != CACHE_VERSION {
295 return None;
296 }
297
298 let mut data = vec![0u8; header.data_len as usize];
300 file.read_exact(&mut data).ok()?;
301
302 let entry: CacheEntry = rkyv::from_bytes::<CacheEntry, rkyv::rancor::Error>(&data).ok()?;
304
305 let file_paths = entry.file_paths();
307 let file_refs: Vec<&Path> = file_paths.iter().map(PathBuf::as_path).collect();
308 let expected_hash = compute_hash(&file_refs);
309 if header.hash != expected_hash {
310 return None;
311 }
312
313 Some(entry)
314}
315
316pub fn save_cache_entry(main_file: &Path, entry: &CacheEntry) -> Result<(), std::io::Error> {
318 let cache_file = cache_path(main_file);
319
320 let file_paths = entry.file_paths();
322 let file_refs: Vec<&Path> = file_paths.iter().map(PathBuf::as_path).collect();
323 let hash = compute_hash(&file_refs);
324
325 let data = rkyv::to_bytes::<rkyv::rancor::Error>(entry)
327 .map(|v| v.to_vec())
328 .map_err(|e| std::io::Error::other(e.to_string()))?;
329
330 let header = CacheHeader {
332 magic: *CACHE_MAGIC,
333 version: CACHE_VERSION,
334 hash,
335 data_len: data.len() as u64,
336 };
337
338 let mut file = fs::File::create(&cache_file)?;
339 file.write_all(&header.to_bytes())?;
340 file.write_all(&data)?;
341
342 Ok(())
343}
344
345#[cfg(test)]
347fn serialize_directives(directives: &Vec<Spanned<Directive>>) -> Result<Vec<u8>, std::io::Error> {
348 rkyv::to_bytes::<rkyv::rancor::Error>(directives)
349 .map(|v| v.to_vec())
350 .map_err(|e| std::io::Error::other(e.to_string()))
351}
352
353#[cfg(test)]
355fn deserialize_directives(data: &[u8]) -> Option<Vec<Spanned<Directive>>> {
356 rkyv::from_bytes::<Vec<Spanned<Directive>>, rkyv::rancor::Error>(data).ok()
357}
358
359pub fn invalidate_cache(main_file: &Path) {
361 let cache_file = cache_path(main_file);
362 let _ = fs::remove_file(cache_file);
363}
364
365pub fn reintern_directives(directives: &mut [Spanned<Directive>]) -> usize {
375 let mut interner = StringInterner::with_capacity(1024);
376 let mut dedup_count = 0;
377 for spanned in directives.iter_mut() {
378 dedup_count += reintern_directive(&mut spanned.value, &mut interner);
379 }
380 dedup_count
381}
382
383pub fn reintern_plain_directives(directives: &mut [Directive]) -> usize {
387 let mut interner = StringInterner::with_capacity(1024);
388 let mut dedup_count = 0;
389 for directive in directives.iter_mut() {
390 dedup_count += reintern_directive(directive, &mut interner);
391 }
392 dedup_count
393}
394
395fn reintern_directive(directive: &mut Directive, interner: &mut StringInterner) -> usize {
398 use rustledger_core::intern::InternedStr;
399 use rustledger_core::{IncompleteAmount, PriceAnnotation};
400
401 fn do_intern(s: &mut InternedStr, interner: &mut StringInterner) -> bool {
402 let already_exists = interner.contains(s.as_str());
403 *s = interner.intern(s.as_str());
404 already_exists
405 }
406
407 let mut dedup_count = 0;
408
409 match directive {
410 Directive::Transaction(txn) => {
411 for posting in &mut txn.postings {
412 if do_intern(&mut posting.account, interner) {
413 dedup_count += 1;
414 }
415 if let Some(ref mut units) = posting.units {
417 match units {
418 IncompleteAmount::Complete(amt) => {
419 if do_intern(&mut amt.currency, interner) {
420 dedup_count += 1;
421 }
422 }
423 IncompleteAmount::CurrencyOnly(cur) => {
424 if do_intern(cur, interner) {
425 dedup_count += 1;
426 }
427 }
428 IncompleteAmount::NumberOnly(_) => {}
429 }
430 }
431 if let Some(ref mut cost) = posting.cost
433 && let Some(ref mut cur) = cost.currency
434 && do_intern(cur, interner)
435 {
436 dedup_count += 1;
437 }
438 if let Some(ref mut price) = posting.price {
440 match price {
441 PriceAnnotation::Unit(amt) | PriceAnnotation::Total(amt) => {
442 if do_intern(&mut amt.currency, interner) {
443 dedup_count += 1;
444 }
445 }
446 PriceAnnotation::UnitIncomplete(inc)
447 | PriceAnnotation::TotalIncomplete(inc) => match inc {
448 IncompleteAmount::Complete(amt) => {
449 if do_intern(&mut amt.currency, interner) {
450 dedup_count += 1;
451 }
452 }
453 IncompleteAmount::CurrencyOnly(cur) => {
454 if do_intern(cur, interner) {
455 dedup_count += 1;
456 }
457 }
458 IncompleteAmount::NumberOnly(_) => {}
459 },
460 PriceAnnotation::UnitEmpty | PriceAnnotation::TotalEmpty => {}
461 }
462 }
463 }
464 }
465 Directive::Balance(bal) => {
466 if do_intern(&mut bal.account, interner) {
467 dedup_count += 1;
468 }
469 if do_intern(&mut bal.amount.currency, interner) {
470 dedup_count += 1;
471 }
472 }
473 Directive::Open(open) => {
474 if do_intern(&mut open.account, interner) {
475 dedup_count += 1;
476 }
477 for cur in &mut open.currencies {
478 if do_intern(cur, interner) {
479 dedup_count += 1;
480 }
481 }
482 }
483 Directive::Close(close) => {
484 if do_intern(&mut close.account, interner) {
485 dedup_count += 1;
486 }
487 }
488 Directive::Commodity(comm) => {
489 if do_intern(&mut comm.currency, interner) {
490 dedup_count += 1;
491 }
492 }
493 Directive::Pad(pad) => {
494 if do_intern(&mut pad.account, interner) {
495 dedup_count += 1;
496 }
497 if do_intern(&mut pad.source_account, interner) {
498 dedup_count += 1;
499 }
500 }
501 Directive::Note(note) => {
502 if do_intern(&mut note.account, interner) {
503 dedup_count += 1;
504 }
505 }
506 Directive::Document(doc) => {
507 if do_intern(&mut doc.account, interner) {
508 dedup_count += 1;
509 }
510 }
511 Directive::Price(price) => {
512 if do_intern(&mut price.currency, interner) {
513 dedup_count += 1;
514 }
515 if do_intern(&mut price.amount.currency, interner) {
516 dedup_count += 1;
517 }
518 }
519 Directive::Event(_) | Directive::Query(_) | Directive::Custom(_) => {
520 }
522 }
523
524 dedup_count
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530 use chrono::NaiveDate;
531 use rust_decimal_macros::dec;
532 use rustledger_core::{Amount, Posting, Transaction};
533 use rustledger_parser::Span;
534
535 #[test]
536 fn test_cache_header_roundtrip() {
537 let header = CacheHeader {
538 magic: *CACHE_MAGIC,
539 version: CACHE_VERSION,
540 hash: [42u8; 32],
541 data_len: 12345,
542 };
543
544 let bytes = header.to_bytes();
545 let parsed = CacheHeader::from_bytes(&bytes).unwrap();
546
547 assert_eq!(parsed.magic, header.magic);
548 assert_eq!(parsed.version, header.version);
549 assert_eq!(parsed.hash, header.hash);
550 assert_eq!(parsed.data_len, header.data_len);
551 }
552
553 #[test]
554 fn test_compute_hash_deterministic() {
555 let files: Vec<&Path> = vec![];
556 let hash1 = compute_hash(&files);
557 let hash2 = compute_hash(&files);
558 assert_eq!(hash1, hash2);
559 }
560
561 #[test]
562 fn test_serialize_deserialize_roundtrip() {
563 let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
564
565 let txn = Transaction::new(date, "Test transaction")
566 .with_payee("Test Payee")
567 .with_posting(Posting::new(
568 "Expenses:Test",
569 Amount::new(dec!(100.00), "USD"),
570 ))
571 .with_posting(Posting::auto("Assets:Checking"));
572
573 let directives = vec![Spanned::new(Directive::Transaction(txn), Span::new(0, 100))];
574
575 let serialized = serialize_directives(&directives).expect("serialization failed");
577
578 let deserialized = deserialize_directives(&serialized).expect("deserialization failed");
580
581 assert_eq!(directives.len(), deserialized.len());
583 let orig_txn = directives[0].value.as_transaction().unwrap();
584 let deser_txn = deserialized[0].value.as_transaction().unwrap();
585
586 assert_eq!(orig_txn.date, deser_txn.date);
587 assert_eq!(orig_txn.payee, deser_txn.payee);
588 assert_eq!(orig_txn.narration, deser_txn.narration);
589 assert_eq!(orig_txn.postings.len(), deser_txn.postings.len());
590
591 assert_eq!(orig_txn.postings[0].account, deser_txn.postings[0].account);
593 assert_eq!(orig_txn.postings[0].units, deser_txn.postings[0].units);
594 }
595
596 #[test]
597 #[ignore = "manual benchmark - run with: cargo test -p rustledger-loader --release -- --ignored --nocapture"]
598 fn bench_cache_performance() {
599 let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
601 let mut directives = Vec::with_capacity(10000);
602
603 for i in 0..10000 {
604 let txn = Transaction::new(date, format!("Transaction {i}"))
605 .with_payee("Store")
606 .with_posting(Posting::new(
607 "Expenses:Food",
608 Amount::new(dec!(25.00), "USD"),
609 ))
610 .with_posting(Posting::auto("Assets:Checking"));
611
612 directives.push(Spanned::new(Directive::Transaction(txn), Span::new(0, 100)));
613 }
614
615 println!("\n=== Cache Benchmark (10,000 directives) ===");
616
617 let start = std::time::Instant::now();
619 let serialized = serialize_directives(&directives).unwrap();
620 let serialize_time = start.elapsed();
621 println!(
622 "Serialize: {:?} ({:.2} MB)",
623 serialize_time,
624 serialized.len() as f64 / 1_000_000.0
625 );
626
627 let start = std::time::Instant::now();
629 let deserialized = deserialize_directives(&serialized).unwrap();
630 let deserialize_time = start.elapsed();
631 println!("Deserialize: {deserialize_time:?}");
632
633 assert_eq!(directives.len(), deserialized.len());
634
635 println!(
636 "\nSpeedup potential: If parsing takes 100ms, cache load would be {:.1}x faster",
637 100.0 / deserialize_time.as_millis() as f64
638 );
639 }
640
641 #[test]
642 fn test_cache_path() {
643 let source = Path::new("/tmp/ledger.beancount");
644 let cache = cache_path(source);
645 assert_eq!(cache, Path::new("/tmp/ledger.beancount.cache"));
646
647 let source2 = Path::new("relative/path/my.beancount");
648 let cache2 = cache_path(source2);
649 assert_eq!(cache2, Path::new("relative/path/my.beancount.cache"));
650 }
651
652 #[test]
653 fn test_save_load_cache_entry_roundtrip() {
654 use std::io::Write;
655
656 let temp_dir = std::env::temp_dir().join("rustledger_cache_test");
658 let _ = fs::create_dir_all(&temp_dir);
659
660 let beancount_file = temp_dir.join("test.beancount");
662 let mut f = fs::File::create(&beancount_file).unwrap();
663 writeln!(f, "2024-01-01 open Assets:Test").unwrap();
664 drop(f);
665
666 let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
668 let txn = Transaction::new(date, "Test").with_posting(Posting::auto("Assets:Test"));
669 let directives = vec![Spanned::new(Directive::Transaction(txn), Span::new(0, 50))];
670
671 let entry = CacheEntry {
672 directives,
673 options: CachedOptions::from(&Options::new()),
674 plugins: vec![CachedPlugin {
675 name: "test_plugin".to_string(),
676 config: Some("config".to_string()),
677 force_python: false,
678 }],
679 files: vec![beancount_file.to_string_lossy().to_string()],
680 };
681
682 save_cache_entry(&beancount_file, &entry).expect("save failed");
684
685 let loaded = load_cache_entry(&beancount_file).expect("load failed");
687
688 assert_eq!(loaded.directives.len(), entry.directives.len());
690 assert_eq!(loaded.plugins.len(), 1);
691 assert_eq!(loaded.plugins[0].name, "test_plugin");
692 assert_eq!(loaded.plugins[0].config, Some("config".to_string()));
693 assert_eq!(loaded.files.len(), 1);
694
695 let _ = fs::remove_file(&beancount_file);
697 let _ = fs::remove_file(cache_path(&beancount_file));
698 let _ = fs::remove_dir(&temp_dir);
699 }
700
701 #[test]
702 fn test_invalidate_cache() {
703 use std::io::Write;
704
705 let temp_dir = std::env::temp_dir().join("rustledger_invalidate_test");
706 let _ = fs::create_dir_all(&temp_dir);
707
708 let beancount_file = temp_dir.join("test.beancount");
709 let mut f = fs::File::create(&beancount_file).unwrap();
710 writeln!(f, "2024-01-01 open Assets:Test").unwrap();
711 drop(f);
712
713 let entry = CacheEntry {
715 directives: vec![],
716 options: CachedOptions::from(&Options::new()),
717 plugins: vec![],
718 files: vec![beancount_file.to_string_lossy().to_string()],
719 };
720 save_cache_entry(&beancount_file, &entry).unwrap();
721
722 assert!(cache_path(&beancount_file).exists());
724
725 invalidate_cache(&beancount_file);
727
728 assert!(!cache_path(&beancount_file).exists());
730
731 let _ = fs::remove_file(&beancount_file);
733 let _ = fs::remove_dir(&temp_dir);
734 }
735
736 #[test]
737 fn test_load_cache_missing_file() {
738 let missing = Path::new("/nonexistent/path/to/file.beancount");
739 assert!(load_cache_entry(missing).is_none());
740 }
741
742 #[test]
743 fn test_load_cache_invalid_magic() {
744 use std::io::Write;
745
746 let temp_dir = std::env::temp_dir().join("rustledger_magic_test");
747 let _ = fs::create_dir_all(&temp_dir);
748
749 let cache_file = temp_dir.join("test.beancount.cache");
750 let mut f = fs::File::create(&cache_file).unwrap();
751 f.write_all(b"INVALID\0").unwrap();
753 f.write_all(&[0u8; CacheHeader::SIZE - 8]).unwrap();
754 drop(f);
755
756 let beancount_file = temp_dir.join("test.beancount");
757 assert!(load_cache_entry(&beancount_file).is_none());
758
759 let _ = fs::remove_file(&cache_file);
761 let _ = fs::remove_dir(&temp_dir);
762 }
763
764 #[test]
765 fn test_reintern_directives_deduplication() {
766 let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
767
768 let mut directives = vec![];
770 for i in 0..5 {
771 let txn = Transaction::new(date, format!("Txn {i}"))
772 .with_posting(Posting::new(
773 "Expenses:Food",
774 Amount::new(dec!(10.00), "USD"),
775 ))
776 .with_posting(Posting::auto("Assets:Checking"));
777 directives.push(Spanned::new(Directive::Transaction(txn), Span::new(0, 50)));
778 }
779
780 let dedup_count = reintern_directives(&mut directives);
782
783 assert_eq!(dedup_count, 12);
789 }
790
791 #[test]
792 fn test_cached_options_roundtrip() {
793 let mut opts = Options::new();
794 opts.title = Some("Test Ledger".to_string());
795 opts.operating_currency = vec!["USD".to_string(), "EUR".to_string()];
796 opts.render_commas = true;
797
798 let cached = CachedOptions::from(&opts);
799 let restored: Options = cached.into();
800
801 assert_eq!(restored.title, Some("Test Ledger".to_string()));
802 assert_eq!(restored.operating_currency, vec!["USD", "EUR"]);
803 assert!(restored.render_commas);
804 }
805
806 #[test]
807 fn test_cache_entry_file_paths() {
808 let entry = CacheEntry {
809 directives: vec![],
810 options: CachedOptions::from(&Options::new()),
811 plugins: vec![],
812 files: vec![
813 "/path/to/ledger.beancount".to_string(),
814 "/path/to/include.beancount".to_string(),
815 ],
816 };
817
818 let paths = entry.file_paths();
819 assert_eq!(paths.len(), 2);
820 assert_eq!(paths[0], PathBuf::from("/path/to/ledger.beancount"));
821 assert_eq!(paths[1], PathBuf::from("/path/to/include.beancount"));
822 }
823
824 #[test]
825 fn test_reintern_balance_directive() {
826 use rustledger_core::Balance;
827
828 let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
829 let balance = Balance::new(date, "Assets:Checking", Amount::new(dec!(1000.00), "USD"));
830
831 let mut directives = vec![
832 Spanned::new(Directive::Balance(balance.clone()), Span::new(0, 50)),
833 Spanned::new(Directive::Balance(balance), Span::new(51, 100)),
834 ];
835
836 let dedup_count = reintern_directives(&mut directives);
837 assert_eq!(dedup_count, 2);
839 }
840
841 #[test]
842 fn test_reintern_open_close_directives() {
843 use rustledger_core::{Close, Open};
844
845 let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
846 let open = Open::new(date, "Assets:Checking");
847 let close = Close::new(date, "Assets:Checking");
848
849 let mut directives = vec![
850 Spanned::new(Directive::Open(open), Span::new(0, 50)),
851 Spanned::new(Directive::Close(close), Span::new(51, 100)),
852 ];
853
854 let dedup_count = reintern_directives(&mut directives);
855 assert_eq!(dedup_count, 1);
857 }
858}