1use crate::Options;
32use blake3::Hasher;
33use rust_decimal::Decimal;
34use rustledger_core::Directive;
35use rustledger_parser::Spanned;
36use std::fs;
37use std::io::{Read, Write};
38use std::path::{Path, PathBuf};
39use std::str::FromStr;
40
41#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
43pub struct CachedPlugin {
44 pub name: String,
46 pub config: Option<String>,
48 pub force_python: bool,
50}
51
52#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
63#[allow(missing_docs)]
64pub struct CachedOptions {
65 pub title: Option<String>,
66 pub filename: Option<String>,
67 pub operating_currency: Vec<String>,
68 pub name_assets: String,
69 pub name_liabilities: String,
70 pub name_equity: String,
71 pub name_income: String,
72 pub name_expenses: String,
73 pub account_rounding: Option<String>,
74 pub account_previous_balances: String,
75 pub account_previous_earnings: String,
76 pub account_previous_conversions: String,
77 pub account_current_earnings: String,
78 pub account_current_conversions: Option<String>,
79 pub account_unrealized_gains: Option<String>,
80 pub conversion_currency: Option<String>,
81 pub inferred_tolerance_default: Vec<(String, String)>,
83 pub inferred_tolerance_multiplier: String,
84 pub infer_tolerance_from_cost: bool,
85 pub use_legacy_fixed_tolerances: bool,
86 pub experiment_explicit_tolerances: bool,
87 pub booking_method: String,
88 pub render_commas: bool,
89 pub allow_pipe_separator: bool,
90 pub long_string_maxlines: u32,
91 pub documents: Vec<String>,
92 pub custom: Vec<(String, String)>,
93 pub set_options: Vec<String>,
98}
99
100impl From<&Options> for CachedOptions {
101 fn from(opts: &Options) -> Self {
102 Self {
103 title: opts.title.clone(),
104 filename: opts.filename.clone(),
105 operating_currency: opts.operating_currency.clone(),
106 name_assets: opts.name_assets.clone(),
107 name_liabilities: opts.name_liabilities.clone(),
108 name_equity: opts.name_equity.clone(),
109 name_income: opts.name_income.clone(),
110 name_expenses: opts.name_expenses.clone(),
111 account_rounding: opts.account_rounding.clone(),
112 account_previous_balances: opts.account_previous_balances.clone(),
113 account_previous_earnings: opts.account_previous_earnings.clone(),
114 account_previous_conversions: opts.account_previous_conversions.clone(),
115 account_current_earnings: opts.account_current_earnings.clone(),
116 account_current_conversions: opts.account_current_conversions.clone(),
117 account_unrealized_gains: opts.account_unrealized_gains.clone(),
118 conversion_currency: opts.conversion_currency.clone(),
119 inferred_tolerance_default: opts
120 .inferred_tolerance_default
121 .iter()
122 .map(|(k, v)| (k.clone(), v.to_string()))
123 .collect(),
124 inferred_tolerance_multiplier: opts.inferred_tolerance_multiplier.to_string(),
125 infer_tolerance_from_cost: opts.infer_tolerance_from_cost,
126 use_legacy_fixed_tolerances: opts.use_legacy_fixed_tolerances,
127 experiment_explicit_tolerances: opts.experiment_explicit_tolerances,
128 booking_method: opts.booking_method.clone(),
129 render_commas: opts.render_commas,
130 allow_pipe_separator: opts.allow_pipe_separator,
131 long_string_maxlines: opts.long_string_maxlines,
132 documents: opts.documents.clone(),
133 custom: opts
134 .custom
135 .iter()
136 .map(|(k, v)| (k.clone(), v.clone()))
137 .collect(),
138 set_options: opts.set_options.iter().cloned().collect(),
139 }
140 }
141}
142
143impl From<CachedOptions> for Options {
144 fn from(cached: CachedOptions) -> Self {
145 let mut opts = Self::new();
146 opts.title = cached.title;
147 opts.filename = cached.filename;
148 opts.operating_currency = cached.operating_currency;
149 opts.name_assets = cached.name_assets;
150 opts.name_liabilities = cached.name_liabilities;
151 opts.name_equity = cached.name_equity;
152 opts.name_income = cached.name_income;
153 opts.name_expenses = cached.name_expenses;
154 opts.account_rounding = cached.account_rounding;
155 opts.account_previous_balances = cached.account_previous_balances;
156 opts.account_previous_earnings = cached.account_previous_earnings;
157 opts.account_previous_conversions = cached.account_previous_conversions;
158 opts.account_current_earnings = cached.account_current_earnings;
159 opts.account_current_conversions = cached.account_current_conversions;
160 opts.account_unrealized_gains = cached.account_unrealized_gains;
161 opts.conversion_currency = cached.conversion_currency;
162 opts.inferred_tolerance_default = cached
163 .inferred_tolerance_default
164 .into_iter()
165 .filter_map(|(k, v)| Decimal::from_str(&v).ok().map(|d| (k, d)))
166 .collect();
167 opts.inferred_tolerance_multiplier =
168 Decimal::from_str(&cached.inferred_tolerance_multiplier)
169 .unwrap_or_else(|_| Decimal::new(5, 1));
170 opts.infer_tolerance_from_cost = cached.infer_tolerance_from_cost;
171 opts.use_legacy_fixed_tolerances = cached.use_legacy_fixed_tolerances;
172 opts.experiment_explicit_tolerances = cached.experiment_explicit_tolerances;
173 opts.booking_method = cached.booking_method;
174 opts.render_commas = cached.render_commas;
175 opts.allow_pipe_separator = cached.allow_pipe_separator;
176 opts.long_string_maxlines = cached.long_string_maxlines;
177 opts.documents = cached.documents;
178 opts.custom = cached.custom.into_iter().collect();
179 opts.set_options = cached.set_options.into_iter().collect();
180 opts
181 }
182}
183
184#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
186pub struct CacheEntry {
187 pub directives: Vec<Spanned<Directive>>,
189 pub options: CachedOptions,
191 pub plugins: Vec<CachedPlugin>,
193 pub files: Vec<String>,
195}
196
197impl CacheEntry {
198 pub fn file_paths(&self) -> Vec<PathBuf> {
200 self.files.iter().map(PathBuf::from).collect()
201 }
202
203 #[must_use]
224 pub fn into_load_result(self) -> crate::LoadResult {
225 let mut source_map = crate::SourceMap::new();
226 for path in self.file_paths() {
227 if let Ok(bytes) = fs::read(&path) {
233 let content = String::from_utf8_lossy(&bytes).into_owned();
234 source_map.add_file(path, content.into());
235 }
236 }
237
238 let plugins: Vec<crate::Plugin> = self
239 .plugins
240 .iter()
241 .map(|p| crate::Plugin {
242 name: p.name.clone(),
243 config: p.config.clone(),
244 span: rustledger_parser::Span::ZERO,
245 file_id: 0,
246 force_python: p.force_python,
247 })
248 .collect();
249
250 let options: Options = self.options.into();
251 let display_context = crate::build_display_context(&self.directives, &options);
252
253 crate::LoadResult {
254 directives: self.directives,
255 options,
256 plugins,
257 source_map,
258 errors: Vec::new(),
259 display_context,
260 }
261 }
262}
263
264const CACHE_MAGIC: &[u8; 8] = b"RLEDGER\0";
266
267const CACHE_VERSION: u32 = 9;
318
319#[derive(Debug, Clone)]
321struct CacheHeader {
322 magic: [u8; 8],
324 version: u32,
326 hash: [u8; 32],
328 data_len: u64,
330}
331
332impl CacheHeader {
333 const SIZE: usize = 8 + 4 + 32 + 8;
334
335 fn to_bytes(&self) -> [u8; Self::SIZE] {
336 let mut buf = [0u8; Self::SIZE];
337 buf[0..8].copy_from_slice(&self.magic);
338 buf[8..12].copy_from_slice(&self.version.to_le_bytes());
339 buf[12..44].copy_from_slice(&self.hash);
340 buf[44..52].copy_from_slice(&self.data_len.to_le_bytes());
341 buf
342 }
343
344 fn from_bytes(bytes: &[u8]) -> Option<Self> {
345 if bytes.len() < Self::SIZE {
346 return None;
347 }
348
349 let mut magic = [0u8; 8];
350 magic.copy_from_slice(&bytes[0..8]);
351
352 let version = u32::from_le_bytes(bytes[8..12].try_into().ok()?);
353
354 let mut hash = [0u8; 32];
355 hash.copy_from_slice(&bytes[12..44]);
356
357 let data_len = u64::from_le_bytes(bytes[44..52].try_into().ok()?);
358
359 Some(Self {
360 magic,
361 version,
362 hash,
363 data_len,
364 })
365 }
366}
367
368fn compute_hash(files: &[&Path]) -> [u8; 32] {
374 let mut hasher = Hasher::new();
375
376 for file in files {
377 hasher.update(file.to_string_lossy().as_bytes());
379
380 if let Ok(metadata) = fs::metadata(file) {
382 if let Ok(mtime) = metadata.modified()
383 && let Ok(duration) = mtime.duration_since(std::time::UNIX_EPOCH)
384 {
385 hasher.update(&duration.as_secs().to_le_bytes());
386 hasher.update(&duration.subsec_nanos().to_le_bytes());
387 }
388 hasher.update(&metadata.len().to_le_bytes());
390 }
391 }
392
393 *hasher.finalize().as_bytes()
394}
395
396pub const CACHE_FILENAME_ENV: &str = "BEANCOUNT_LOAD_CACHE_FILENAME";
403
404pub const DISABLE_CACHE_ENV: &str = "BEANCOUNT_DISABLE_LOAD_CACHE";
408
409pub fn cache_path(source: &Path) -> PathBuf {
427 if let Ok(pattern) = std::env::var(CACHE_FILENAME_ENV)
428 && !pattern.is_empty()
429 {
430 return resolve_cache_pattern(source, &pattern);
431 }
432 default_cache_path(source)
433}
434
435#[must_use]
441pub fn default_cache_path(source: &Path) -> PathBuf {
442 let mut path = source.to_path_buf();
443 let name = path.file_name().map_or_else(
444 || ".ledger.cache".to_string(),
445 |n| format!(".{}.cache", n.to_string_lossy()),
446 );
447 path.set_file_name(name);
448 path
449}
450
451#[allow(clippy::literal_string_with_formatting_args)]
457fn resolve_cache_pattern(source: &Path, pattern: &str) -> PathBuf {
458 let filename = source.file_name().map_or_else(
459 || "ledger".to_string(),
460 |n| n.to_string_lossy().into_owned(),
461 );
462 let resolved = pattern.replace("{filename}", &filename);
463 let p = PathBuf::from(&resolved);
464 if p.is_absolute() {
465 return p;
466 }
467 source.parent().map_or(p.clone(), |parent| parent.join(&p))
468}
469
470fn legacy_cache_path(source: &Path) -> PathBuf {
475 let mut path = source.to_path_buf();
476 let name = path.file_name().map_or_else(
477 || "ledger.cache".to_string(),
478 |n| format!("{}.cache", n.to_string_lossy()),
479 );
480 path.set_file_name(name);
481 path
482}
483
484#[must_use]
490pub fn cache_disabled_by_env() -> bool {
491 std::env::var_os(DISABLE_CACHE_ENV).is_some()
492}
493
494pub fn load_cache_entry(main_file: &Path) -> Option<CacheEntry> {
500 if cache_disabled_by_env() {
501 return None;
502 }
503 let cache_file = cache_path(main_file);
504 let mut file = fs::File::open(&cache_file).ok()?;
505
506 let mut header_bytes = [0u8; CacheHeader::SIZE];
508 file.read_exact(&mut header_bytes).ok()?;
509 let header = CacheHeader::from_bytes(&header_bytes)?;
510
511 if header.magic != *CACHE_MAGIC {
513 return None;
514 }
515 if header.version != CACHE_VERSION {
516 return None;
517 }
518
519 let mut data = vec![0u8; header.data_len as usize];
521 file.read_exact(&mut data).ok()?;
522
523 let entry: CacheEntry = rkyv::from_bytes::<CacheEntry, rkyv::rancor::Error>(&data).ok()?;
525
526 let file_paths = entry.file_paths();
528 let file_refs: Vec<&Path> = file_paths.iter().map(PathBuf::as_path).collect();
529 let expected_hash = compute_hash(&file_refs);
530 if header.hash != expected_hash {
531 return None;
532 }
533
534 Some(entry)
535}
536
537pub fn save_cache_entry(main_file: &Path, entry: &CacheEntry) -> Result<(), std::io::Error> {
541 if cache_disabled_by_env() {
542 return Ok(());
543 }
544 let cache_file = cache_path(main_file);
545
546 let file_paths = entry.file_paths();
548 let file_refs: Vec<&Path> = file_paths.iter().map(PathBuf::as_path).collect();
549 let hash = compute_hash(&file_refs);
550
551 let data = rkyv::to_bytes::<rkyv::rancor::Error>(entry)
553 .map(|v| v.to_vec())
554 .map_err(|e| std::io::Error::other(e.to_string()))?;
555
556 let header = CacheHeader {
558 magic: *CACHE_MAGIC,
559 version: CACHE_VERSION,
560 hash,
561 data_len: data.len() as u64,
562 };
563
564 if let Some(parent) = cache_file.parent()
568 && !parent.as_os_str().is_empty()
569 {
570 fs::create_dir_all(parent)?;
571 }
572
573 let mut file = fs::File::create(&cache_file)?;
574 file.write_all(&header.to_bytes())?;
575 file.write_all(&data)?;
576
577 let legacy = legacy_cache_path(main_file);
582 if legacy != cache_file && legacy.exists() {
583 let _ = fs::remove_file(&legacy);
584 }
585
586 Ok(())
587}
588
589#[cfg(test)]
591fn serialize_directives(directives: &Vec<Spanned<Directive>>) -> Result<Vec<u8>, std::io::Error> {
592 rkyv::to_bytes::<rkyv::rancor::Error>(directives)
593 .map(|v| v.to_vec())
594 .map_err(|e| std::io::Error::other(e.to_string()))
595}
596
597#[cfg(test)]
599fn deserialize_directives(data: &[u8]) -> Option<Vec<Spanned<Directive>>> {
600 rkyv::from_bytes::<Vec<Spanned<Directive>>, rkyv::rancor::Error>(data).ok()
601}
602
603pub fn invalidate_cache(main_file: &Path) {
608 let cache_file = cache_path(main_file);
609 let _ = fs::remove_file(&cache_file);
610
611 let legacy = legacy_cache_path(main_file);
612 if legacy != cache_file {
613 let _ = fs::remove_file(&legacy);
614 }
615}
616
617#[cfg(test)]
618mod tests {
619 use super::*;
620 use crate::dedup::reintern_directives;
621 use rust_decimal_macros::dec;
622 use rustledger_core::{Amount, Posting, Transaction};
623 use rustledger_parser::Span;
624
625 #[test]
626 fn test_cache_header_roundtrip() {
627 let header = CacheHeader {
628 magic: *CACHE_MAGIC,
629 version: CACHE_VERSION,
630 hash: [42u8; 32],
631 data_len: 12345,
632 };
633
634 let bytes = header.to_bytes();
635 let parsed = CacheHeader::from_bytes(&bytes).unwrap();
636
637 assert_eq!(parsed.magic, header.magic);
638 assert_eq!(parsed.version, header.version);
639 assert_eq!(parsed.hash, header.hash);
640 assert_eq!(parsed.data_len, header.data_len);
641 }
642
643 #[test]
644 fn test_compute_hash_deterministic() {
645 let files: Vec<&Path> = vec![];
646 let hash1 = compute_hash(&files);
647 let hash2 = compute_hash(&files);
648 assert_eq!(hash1, hash2);
649 }
650
651 #[test]
652 fn test_serialize_deserialize_roundtrip() {
653 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
654
655 let txn = Transaction::new(date, "Test transaction")
656 .with_payee("Test Payee")
657 .with_synthesized_posting(Posting::new(
658 "Expenses:Test",
659 Amount::new(dec!(100.00), "USD"),
660 ))
661 .with_synthesized_posting(Posting::auto("Assets:Checking"));
662
663 let directives = vec![Spanned::new(Directive::Transaction(txn), Span::new(0, 100))];
664
665 let serialized = serialize_directives(&directives).expect("serialization failed");
667
668 let deserialized = deserialize_directives(&serialized).expect("deserialization failed");
670
671 assert_eq!(directives.len(), deserialized.len());
673 let orig_txn = directives[0].value.as_transaction().unwrap();
674 let deser_txn = deserialized[0].value.as_transaction().unwrap();
675
676 assert_eq!(orig_txn.date, deser_txn.date);
677 assert_eq!(orig_txn.payee, deser_txn.payee);
678 assert_eq!(orig_txn.narration, deser_txn.narration);
679 assert_eq!(orig_txn.postings.len(), deser_txn.postings.len());
680
681 assert_eq!(orig_txn.postings[0].account, deser_txn.postings[0].account);
683 assert_eq!(orig_txn.postings[0].units, deser_txn.postings[0].units);
684 }
685
686 #[test]
687 #[ignore = "manual benchmark - run with: cargo test -p rustledger-loader --release -- --ignored --nocapture"]
688 fn bench_cache_performance() {
689 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
691 let mut directives = Vec::with_capacity(10000);
692
693 for i in 0..10000 {
694 let txn = Transaction::new(date, format!("Transaction {i}"))
695 .with_payee("Store")
696 .with_synthesized_posting(Posting::new(
697 "Expenses:Food",
698 Amount::new(dec!(25.00), "USD"),
699 ))
700 .with_synthesized_posting(Posting::auto("Assets:Checking"));
701
702 directives.push(Spanned::new(Directive::Transaction(txn), Span::new(0, 100)));
703 }
704
705 println!("\n=== Cache Benchmark (10,000 directives) ===");
706
707 let start = std::time::Instant::now();
709 let serialized = serialize_directives(&directives).unwrap();
710 let serialize_time = start.elapsed();
711 println!(
712 "Serialize: {:?} ({:.2} MB)",
713 serialize_time,
714 serialized.len() as f64 / 1_000_000.0
715 );
716
717 let start = std::time::Instant::now();
719 let deserialized = deserialize_directives(&serialized).unwrap();
720 let deserialize_time = start.elapsed();
721 println!("Deserialize: {deserialize_time:?}");
722
723 assert_eq!(directives.len(), deserialized.len());
724
725 println!(
726 "\nSpeedup potential: If parsing takes 100ms, cache load would be {:.1}x faster",
727 100.0 / deserialize_time.as_millis() as f64
728 );
729 }
730
731 fn assert_clean_cache_env() {
742 for var in [CACHE_FILENAME_ENV, DISABLE_CACHE_ENV] {
743 assert!(
744 std::env::var_os(var).is_none(),
745 "unset {var} before running this test"
746 );
747 }
748 }
749
750 #[test]
751 fn test_resolve_cache_pattern_relative_with_substitution() {
752 let source = Path::new("/home/user/finances/main.beancount");
753 let resolved = resolve_cache_pattern(source, ".cache/{filename}.bin");
754 assert_eq!(
755 resolved,
756 Path::new("/home/user/finances/.cache/main.beancount.bin")
757 );
758 }
759
760 #[test]
761 fn test_resolve_cache_pattern_absolute() {
762 let source = Path::new("/home/user/main.beancount");
763 let resolved = resolve_cache_pattern(source, "/var/cache/rledger/{filename}.cache");
764 assert_eq!(
765 resolved,
766 Path::new("/var/cache/rledger/main.beancount.cache")
767 );
768 }
769
770 #[test]
771 fn test_resolve_cache_pattern_no_substitution() {
772 let source = Path::new("/home/user/main.beancount");
774 let resolved = resolve_cache_pattern(source, "fixed.cache");
775 assert_eq!(resolved, Path::new("/home/user/fixed.cache"));
776 }
777
778 #[test]
779 fn test_legacy_cache_path() {
780 let source = Path::new("/tmp/ledger.beancount");
781 assert_eq!(
782 legacy_cache_path(source),
783 Path::new("/tmp/ledger.beancount.cache")
784 );
785 }
786
787 #[test]
788 fn test_save_load_cache_entry_roundtrip() {
789 use std::io::Write;
790
791 assert_clean_cache_env();
792
793 let temp_dir = std::env::temp_dir().join("rustledger_cache_test");
795 let _ = fs::create_dir_all(&temp_dir);
796
797 let beancount_file = temp_dir.join("test.beancount");
799 let mut f = fs::File::create(&beancount_file).unwrap();
800 writeln!(f, "2024-01-01 open Assets:Test").unwrap();
801 drop(f);
802
803 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
805 let txn =
806 Transaction::new(date, "Test").with_synthesized_posting(Posting::auto("Assets:Test"));
807 let directives = vec![Spanned::new(Directive::Transaction(txn), Span::new(0, 50))];
808
809 let entry = CacheEntry {
810 directives,
811 options: CachedOptions::from(&Options::new()),
812 plugins: vec![CachedPlugin {
813 name: "test_plugin".to_string(),
814 config: Some("config".to_string()),
815 force_python: false,
816 }],
817 files: vec![beancount_file.to_string_lossy().to_string()],
818 };
819
820 save_cache_entry(&beancount_file, &entry).expect("save failed");
822
823 let loaded = load_cache_entry(&beancount_file).expect("load failed");
825
826 assert_eq!(loaded.directives.len(), entry.directives.len());
828 assert_eq!(loaded.plugins.len(), 1);
829 assert_eq!(loaded.plugins[0].name, "test_plugin");
830 assert_eq!(loaded.plugins[0].config, Some("config".to_string()));
831 assert_eq!(loaded.files.len(), 1);
832
833 let _ = fs::remove_file(&beancount_file);
835 let _ = fs::remove_file(cache_path(&beancount_file));
836 let _ = fs::remove_dir(&temp_dir);
837 }
838
839 #[test]
840 fn test_invalidate_cache() {
841 use std::io::Write;
842
843 assert_clean_cache_env();
844
845 let temp_dir = std::env::temp_dir().join("rustledger_invalidate_test");
846 let _ = fs::create_dir_all(&temp_dir);
847
848 let beancount_file = temp_dir.join("test.beancount");
849 let mut f = fs::File::create(&beancount_file).unwrap();
850 writeln!(f, "2024-01-01 open Assets:Test").unwrap();
851 drop(f);
852
853 let entry = CacheEntry {
855 directives: vec![],
856 options: CachedOptions::from(&Options::new()),
857 plugins: vec![],
858 files: vec![beancount_file.to_string_lossy().to_string()],
859 };
860 save_cache_entry(&beancount_file, &entry).unwrap();
861
862 assert!(cache_path(&beancount_file).exists());
864
865 invalidate_cache(&beancount_file);
867
868 assert!(!cache_path(&beancount_file).exists());
870
871 let _ = fs::remove_file(&beancount_file);
873 let _ = fs::remove_dir(&temp_dir);
874 }
875
876 #[test]
877 fn test_invalidate_cache_removes_legacy_sidecar() {
878 assert_clean_cache_env();
881
882 let temp_dir = std::env::temp_dir().join("rustledger_invalidate_legacy_test");
883 let _ = fs::create_dir_all(&temp_dir);
884
885 let beancount_file = temp_dir.join("legacy.beancount");
886 let legacy = legacy_cache_path(&beancount_file);
889 fs::write(&legacy, b"stale").unwrap();
890 assert!(legacy.exists());
891
892 invalidate_cache(&beancount_file);
893 assert!(
894 !legacy.exists(),
895 "invalidate_cache should remove the legacy sidecar file"
896 );
897
898 let _ = fs::remove_dir(&temp_dir);
899 }
900
901 #[test]
902 fn test_load_cache_missing_file() {
903 let missing = Path::new("/nonexistent/path/to/file.beancount");
904 assert!(load_cache_entry(missing).is_none());
905 }
906
907 #[test]
908 fn test_load_cache_invalid_magic() {
909 use std::io::Write;
910
911 assert_clean_cache_env();
912
913 let temp_dir = std::env::temp_dir().join("rustledger_magic_test");
914 let _ = fs::create_dir_all(&temp_dir);
915
916 let beancount_file = temp_dir.join("test.beancount");
917 let cache_file = cache_path(&beancount_file);
919 let mut f = fs::File::create(&cache_file).unwrap();
920 f.write_all(b"INVALID\0").unwrap();
922 f.write_all(&[0u8; CacheHeader::SIZE - 8]).unwrap();
923 drop(f);
924
925 assert!(load_cache_entry(&beancount_file).is_none());
926
927 let _ = fs::remove_file(&cache_file);
929 let _ = fs::remove_dir(&temp_dir);
930 }
931
932 #[test]
938 fn test_load_cache_rejects_older_version() {
939 use std::io::Write;
940
941 assert_clean_cache_env();
942
943 let temp_dir = std::env::temp_dir().join("rustledger_old_version_test");
944 let _ = fs::create_dir_all(&temp_dir);
945
946 let beancount_file = temp_dir.join("test.beancount");
947 let cache_file = cache_path(&beancount_file);
948 let mut f = fs::File::create(&cache_file).unwrap();
949
950 let stale_version: u32 = CACHE_VERSION.checked_sub(1).expect("CACHE_VERSION >= 1");
954 f.write_all(CACHE_MAGIC).unwrap();
955 f.write_all(&stale_version.to_le_bytes()).unwrap();
956 f.write_all(&[0u8; CacheHeader::SIZE - 8 - 4]).unwrap();
957 drop(f);
958
959 assert!(
960 load_cache_entry(&beancount_file).is_none(),
961 "loader must reject cache files with an older CACHE_VERSION"
962 );
963
964 let _ = fs::remove_file(&cache_file);
965 let _ = fs::remove_dir(&temp_dir);
966 }
967
968 #[cfg(target_endian = "little")]
1001 #[test]
1002 fn cost_number_archived_bytes_match_v8_fixtures() {
1003 use rust_decimal_macros::dec;
1004 use rustledger_core::{BookedCost, CostNumber};
1005
1006 const FIXTURE_VERSION: u32 = 9;
1017 assert_eq!(
1018 CACHE_VERSION, FIXTURE_VERSION,
1019 "CACHE_VERSION advanced past the fixture version; regenerate \
1020 the byte fixtures in this test and update FIXTURE_VERSION, \
1021 or remove the tripwire if v{CACHE_VERSION}'s CostNumber \
1022 encoding is byte-identical to the fixtures.",
1023 );
1024
1025 let cases: &[(&str, CostNumber, &[u8])] = &[
1026 (
1027 "PerUnit { value: 150 }",
1028 CostNumber::PerUnit { value: dec!(150) },
1029 &[
1030 0, 0, 0, 0, 0, 150, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
1031 0, 0, 0, 0, 0, 0, 0,
1032 ],
1033 ),
1034 (
1035 "Total { value: 1500 }",
1036 CostNumber::Total { value: dec!(1500) },
1037 &[
1038 1, 0, 0, 0, 0, 220, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
1039 0, 0, 0, 0, 0, 0, 0,
1040 ],
1041 ),
1042 (
1043 "PerUnitFromTotal { per_unit: 150, total: 300 }",
1044 CostNumber::PerUnitFromTotal(BookedCost::new(dec!(150), dec!(300), dec!(2))),
1045 &[
1046 2, 0, 0, 0, 0, 150, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 44, 1, 0, 0,
1047 0, 0, 0, 0, 0, 0, 0, 0,
1048 ],
1049 ),
1050 ];
1051 let mut mismatches = Vec::new();
1052 for (name, cn, expected) in cases {
1053 let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(cn).unwrap();
1054 if bytes.as_ref() != *expected {
1055 mismatches.push(format!(" `{name}` → {:?}", bytes.as_ref()));
1056 }
1057 }
1058 assert!(
1059 mismatches.is_empty(),
1060 "rkyv layout drifted from v8 fixtures — bump CACHE_VERSION and \
1061 update the fixtures in this test if intentional. Actual bytes:\n{}",
1062 mismatches.join("\n"),
1063 );
1064 }
1065
1066 #[test]
1067 fn test_reintern_directives_deduplication() {
1068 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1069
1070 let mut directives = vec![];
1072 for i in 0..5 {
1073 let txn = Transaction::new(date, format!("Txn {i}"))
1074 .with_synthesized_posting(Posting::new(
1075 "Expenses:Food",
1076 Amount::new(dec!(10.00), "USD"),
1077 ))
1078 .with_synthesized_posting(Posting::auto("Assets:Checking"));
1079 directives.push(Spanned::new(Directive::Transaction(txn), Span::new(0, 50)));
1080 }
1081
1082 let dedup_count = reintern_directives(&mut directives);
1084
1085 assert_eq!(dedup_count, 12);
1091 }
1092
1093 #[test]
1094 fn test_cached_options_roundtrip() {
1095 let mut opts = Options::new();
1096 opts.title = Some("Test Ledger".to_string());
1097 opts.operating_currency = vec!["USD".to_string(), "EUR".to_string()];
1098 opts.render_commas = true;
1099
1100 let cached = CachedOptions::from(&opts);
1101 let restored: Options = cached.into();
1102
1103 assert_eq!(restored.title, Some("Test Ledger".to_string()));
1104 assert_eq!(restored.operating_currency, vec!["USD", "EUR"]);
1105 assert!(restored.render_commas);
1106 }
1107
1108 #[test]
1113 fn test_cached_options_preserves_set_options_for_booking_method() {
1114 let mut opts = Options::new();
1115 opts.set("booking_method", "FIFO");
1118 assert!(opts.set_options.contains("booking_method"));
1119
1120 let cached = CachedOptions::from(&opts);
1121 let restored: Options = cached.into();
1122
1123 assert_eq!(restored.booking_method, "FIFO");
1124 assert!(
1125 restored.set_options.contains("booking_method"),
1126 "set_options dropped across cache round-trip — booking method \
1127 resolution would fall back to the STRICT default on a cache hit"
1128 );
1129 }
1130
1131 #[test]
1132 fn test_cache_entry_file_paths() {
1133 let entry = CacheEntry {
1134 directives: vec![],
1135 options: CachedOptions::from(&Options::new()),
1136 plugins: vec![],
1137 files: vec![
1138 "/path/to/ledger.beancount".to_string(),
1139 "/path/to/include.beancount".to_string(),
1140 ],
1141 };
1142
1143 let paths = entry.file_paths();
1144 assert_eq!(paths.len(), 2);
1145 assert_eq!(paths[0], PathBuf::from("/path/to/ledger.beancount"));
1146 assert_eq!(paths[1], PathBuf::from("/path/to/include.beancount"));
1147 }
1148
1149 #[test]
1150 fn test_reintern_balance_directive() {
1151 use rustledger_core::Balance;
1152
1153 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1154 let balance = Balance::new(date, "Assets:Checking", Amount::new(dec!(1000.00), "USD"));
1155
1156 let mut directives = vec![
1157 Spanned::new(Directive::Balance(balance.clone()), Span::new(0, 50)),
1158 Spanned::new(Directive::Balance(balance), Span::new(51, 100)),
1159 ];
1160
1161 let dedup_count = reintern_directives(&mut directives);
1162 assert_eq!(dedup_count, 2);
1164 }
1165
1166 #[test]
1167 fn test_reintern_open_close_directives() {
1168 use rustledger_core::{Close, Open};
1169
1170 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1171 let open = Open::new(date, "Assets:Checking");
1172 let close = Close::new(date, "Assets:Checking");
1173
1174 let mut directives = vec![
1175 Spanned::new(Directive::Open(open), Span::new(0, 50)),
1176 Spanned::new(Directive::Close(close), Span::new(51, 100)),
1177 ];
1178
1179 let dedup_count = reintern_directives(&mut directives);
1180 assert_eq!(dedup_count, 1);
1182 }
1183}