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 use_precise_interpolation: bool,
88 pub booking_method: String,
89 pub render_commas: bool,
90 pub display_precision: Vec<(String, u32)>,
95 pub allow_pipe_separator: bool,
96 pub long_string_maxlines: u32,
97 pub documents: Vec<String>,
98 pub plugin_processing_mode: String,
99 pub custom: Vec<(String, String)>,
100 pub set_options: Vec<String>,
105}
106
107impl From<&Options> for CachedOptions {
108 fn from(opts: &Options) -> Self {
109 Self {
110 title: opts.title.clone(),
111 filename: opts.filename.clone(),
112 operating_currency: opts.operating_currency.clone(),
113 name_assets: opts.name_assets.clone(),
114 name_liabilities: opts.name_liabilities.clone(),
115 name_equity: opts.name_equity.clone(),
116 name_income: opts.name_income.clone(),
117 name_expenses: opts.name_expenses.clone(),
118 account_rounding: opts.account_rounding.clone(),
119 account_previous_balances: opts.account_previous_balances.clone(),
120 account_previous_earnings: opts.account_previous_earnings.clone(),
121 account_previous_conversions: opts.account_previous_conversions.clone(),
122 account_current_earnings: opts.account_current_earnings.clone(),
123 account_current_conversions: opts.account_current_conversions.clone(),
124 account_unrealized_gains: opts.account_unrealized_gains.clone(),
125 conversion_currency: opts.conversion_currency.clone(),
126 inferred_tolerance_default: opts
127 .inferred_tolerance_default
128 .iter()
129 .map(|(k, v)| (k.clone(), v.to_string()))
130 .collect(),
131 inferred_tolerance_multiplier: opts.inferred_tolerance_multiplier.to_string(),
132 infer_tolerance_from_cost: opts.infer_tolerance_from_cost,
133 use_legacy_fixed_tolerances: opts.use_legacy_fixed_tolerances,
134 experiment_explicit_tolerances: opts.experiment_explicit_tolerances,
135 use_precise_interpolation: opts.use_precise_interpolation,
136 booking_method: opts.booking_method.clone(),
137 render_commas: opts.render_commas,
138 display_precision: opts
139 .display_precision
140 .iter()
141 .map(|(k, v)| (k.clone(), *v))
142 .collect(),
143 allow_pipe_separator: opts.allow_pipe_separator,
144 long_string_maxlines: opts.long_string_maxlines,
145 documents: opts.documents.clone(),
146 plugin_processing_mode: opts.plugin_processing_mode.clone(),
147 custom: opts
148 .custom
149 .iter()
150 .map(|(k, v)| (k.clone(), v.clone()))
151 .collect(),
152 set_options: opts.set_options.iter().cloned().collect(),
153 }
154 }
155}
156
157impl From<CachedOptions> for Options {
158 fn from(cached: CachedOptions) -> Self {
159 let mut opts = Self::new();
160 opts.title = cached.title;
161 opts.filename = cached.filename;
162 opts.operating_currency = cached.operating_currency;
163 opts.name_assets = cached.name_assets;
164 opts.name_liabilities = cached.name_liabilities;
165 opts.name_equity = cached.name_equity;
166 opts.name_income = cached.name_income;
167 opts.name_expenses = cached.name_expenses;
168 opts.account_rounding = cached.account_rounding;
169 opts.account_previous_balances = cached.account_previous_balances;
170 opts.account_previous_earnings = cached.account_previous_earnings;
171 opts.account_previous_conversions = cached.account_previous_conversions;
172 opts.account_current_earnings = cached.account_current_earnings;
173 opts.account_current_conversions = cached.account_current_conversions;
174 opts.account_unrealized_gains = cached.account_unrealized_gains;
175 opts.conversion_currency = cached.conversion_currency;
176 opts.inferred_tolerance_default = cached
177 .inferred_tolerance_default
178 .into_iter()
179 .filter_map(|(k, v)| Decimal::from_str(&v).ok().map(|d| (k, d)))
180 .collect();
181 opts.inferred_tolerance_multiplier =
182 Decimal::from_str(&cached.inferred_tolerance_multiplier)
183 .unwrap_or_else(|_| Decimal::new(5, 1));
184 opts.infer_tolerance_from_cost = cached.infer_tolerance_from_cost;
185 opts.use_legacy_fixed_tolerances = cached.use_legacy_fixed_tolerances;
186 opts.experiment_explicit_tolerances = cached.experiment_explicit_tolerances;
187 opts.use_precise_interpolation = cached.use_precise_interpolation;
188 opts.booking_method = cached.booking_method;
189 opts.render_commas = cached.render_commas;
190 opts.display_precision = cached.display_precision.into_iter().collect();
191 opts.allow_pipe_separator = cached.allow_pipe_separator;
192 opts.long_string_maxlines = cached.long_string_maxlines;
193 opts.documents = cached.documents;
194 opts.plugin_processing_mode = cached.plugin_processing_mode;
195 opts.custom = cached.custom.into_iter().collect();
196 opts.set_options = cached.set_options.into_iter().collect();
197 opts
198 }
199}
200
201#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
203pub struct CacheEntry {
204 pub directives: Vec<Spanned<Directive>>,
206 pub options: CachedOptions,
208 pub plugins: Vec<CachedPlugin>,
210 pub files: Vec<String>,
212}
213
214impl CacheEntry {
215 pub fn file_paths(&self) -> Vec<PathBuf> {
217 self.files.iter().map(PathBuf::from).collect()
218 }
219
220 #[must_use]
241 pub fn into_load_result(self) -> crate::LoadResult {
242 let mut source_map = crate::SourceMap::new();
243 for path in self.file_paths() {
244 if let Ok(bytes) = fs::read(&path) {
250 let content = String::from_utf8_lossy(&bytes).into_owned();
251 source_map.add_file(path, content.into());
252 }
253 }
254
255 let plugins: Vec<crate::Plugin> = self
256 .plugins
257 .iter()
258 .map(|p| crate::Plugin {
259 name: p.name.clone(),
260 config: p.config.clone(),
261 span: rustledger_parser::Span::ZERO,
262 file_id: 0,
263 force_python: p.force_python,
264 })
265 .collect();
266
267 let options: Options = self.options.into();
268 let display_context = crate::build_display_context(&self.directives, &options);
269
270 crate::LoadResult {
271 directives: self.directives,
272 options,
273 plugins,
274 source_map,
275 errors: Vec::new(),
276 display_context,
277 }
278 }
279}
280
281const CACHE_MAGIC: &[u8; 8] = b"RLEDGER\0";
283
284const CACHE_VERSION: u32 = 14;
357
358#[derive(Debug, Clone)]
360struct CacheHeader {
361 magic: [u8; 8],
363 version: u32,
365 hash: [u8; 32],
367 data_len: u64,
369}
370
371impl CacheHeader {
372 const SIZE: usize = 8 + 4 + 32 + 8;
373
374 fn to_bytes(&self) -> [u8; Self::SIZE] {
375 let mut buf = [0u8; Self::SIZE];
376 buf[0..8].copy_from_slice(&self.magic);
377 buf[8..12].copy_from_slice(&self.version.to_le_bytes());
378 buf[12..44].copy_from_slice(&self.hash);
379 buf[44..52].copy_from_slice(&self.data_len.to_le_bytes());
380 buf
381 }
382
383 fn from_bytes(bytes: &[u8]) -> Option<Self> {
384 if bytes.len() < Self::SIZE {
385 return None;
386 }
387
388 let mut magic = [0u8; 8];
389 magic.copy_from_slice(&bytes[0..8]);
390
391 let version = u32::from_le_bytes(bytes[8..12].try_into().ok()?);
392
393 let mut hash = [0u8; 32];
394 hash.copy_from_slice(&bytes[12..44]);
395
396 let data_len = u64::from_le_bytes(bytes[44..52].try_into().ok()?);
397
398 Some(Self {
399 magic,
400 version,
401 hash,
402 data_len,
403 })
404 }
405}
406
407fn compute_hash(files: &[&Path]) -> [u8; 32] {
413 let mut hasher = Hasher::new();
414
415 for file in files {
416 hasher.update(file.to_string_lossy().as_bytes());
418
419 if let Ok(metadata) = fs::metadata(file) {
421 if let Ok(mtime) = metadata.modified()
422 && let Ok(duration) = mtime.duration_since(std::time::UNIX_EPOCH)
423 {
424 hasher.update(&duration.as_secs().to_le_bytes());
425 hasher.update(&duration.subsec_nanos().to_le_bytes());
426 }
427 hasher.update(&metadata.len().to_le_bytes());
429 }
430 }
431
432 *hasher.finalize().as_bytes()
433}
434
435pub const CACHE_FILENAME_ENV: &str = "BEANCOUNT_LOAD_CACHE_FILENAME";
442
443pub const DISABLE_CACHE_ENV: &str = "BEANCOUNT_DISABLE_LOAD_CACHE";
447
448pub fn cache_path(source: &Path) -> PathBuf {
466 if let Ok(pattern) = std::env::var(CACHE_FILENAME_ENV)
467 && !pattern.is_empty()
468 {
469 return resolve_cache_pattern(source, &pattern);
470 }
471 default_cache_path(source)
472}
473
474#[must_use]
480pub fn default_cache_path(source: &Path) -> PathBuf {
481 let mut path = source.to_path_buf();
482 let name = path.file_name().map_or_else(
483 || ".ledger.cache".to_string(),
484 |n| format!(".{}.cache", n.to_string_lossy()),
485 );
486 path.set_file_name(name);
487 path
488}
489
490#[allow(clippy::literal_string_with_formatting_args)]
496fn resolve_cache_pattern(source: &Path, pattern: &str) -> PathBuf {
497 let filename = source.file_name().map_or_else(
498 || "ledger".to_string(),
499 |n| n.to_string_lossy().into_owned(),
500 );
501 let resolved = pattern.replace("{filename}", &filename);
502 let p = PathBuf::from(&resolved);
503 if p.is_absolute() {
504 return p;
505 }
506 source.parent().map_or(p.clone(), |parent| parent.join(&p))
507}
508
509fn legacy_cache_path(source: &Path) -> PathBuf {
514 let mut path = source.to_path_buf();
515 let name = path.file_name().map_or_else(
516 || "ledger.cache".to_string(),
517 |n| format!("{}.cache", n.to_string_lossy()),
518 );
519 path.set_file_name(name);
520 path
521}
522
523#[must_use]
529pub fn cache_disabled_by_env() -> bool {
530 std::env::var_os(DISABLE_CACHE_ENV).is_some()
531}
532
533pub fn load_cache_entry(main_file: &Path) -> Option<CacheEntry> {
539 if cache_disabled_by_env() {
540 return None;
541 }
542 let cache_file = cache_path(main_file);
543 let mut file = fs::File::open(&cache_file).ok()?;
544
545 let mut header_bytes = [0u8; CacheHeader::SIZE];
547 file.read_exact(&mut header_bytes).ok()?;
548 let header = CacheHeader::from_bytes(&header_bytes)?;
549
550 if header.magic != *CACHE_MAGIC {
552 return None;
553 }
554 if header.version != CACHE_VERSION {
555 return None;
556 }
557
558 let mut data = vec![0u8; header.data_len as usize];
560 file.read_exact(&mut data).ok()?;
561
562 let entry: CacheEntry = rkyv::from_bytes::<CacheEntry, rkyv::rancor::Error>(&data).ok()?;
564
565 let file_paths = entry.file_paths();
567 let file_refs: Vec<&Path> = file_paths.iter().map(PathBuf::as_path).collect();
568 let expected_hash = compute_hash(&file_refs);
569 if header.hash != expected_hash {
570 return None;
571 }
572
573 Some(entry)
574}
575
576pub fn save_cache_entry(main_file: &Path, entry: &CacheEntry) -> Result<(), std::io::Error> {
580 if cache_disabled_by_env() {
581 return Ok(());
582 }
583 let cache_file = cache_path(main_file);
584
585 let file_paths = entry.file_paths();
587 let file_refs: Vec<&Path> = file_paths.iter().map(PathBuf::as_path).collect();
588 let hash = compute_hash(&file_refs);
589
590 let data = rkyv::to_bytes::<rkyv::rancor::Error>(entry)
592 .map(|v| v.to_vec())
593 .map_err(|e| std::io::Error::other(e.to_string()))?;
594
595 let header = CacheHeader {
597 magic: *CACHE_MAGIC,
598 version: CACHE_VERSION,
599 hash,
600 data_len: data.len() as u64,
601 };
602
603 if let Some(parent) = cache_file.parent()
607 && !parent.as_os_str().is_empty()
608 {
609 fs::create_dir_all(parent)?;
610 }
611
612 let mut file = fs::File::create(&cache_file)?;
613 file.write_all(&header.to_bytes())?;
614 file.write_all(&data)?;
615
616 let legacy = legacy_cache_path(main_file);
621 if legacy != cache_file && legacy.exists() {
622 let _ = fs::remove_file(&legacy);
623 }
624
625 Ok(())
626}
627
628#[cfg(test)]
630fn serialize_directives(directives: &Vec<Spanned<Directive>>) -> Result<Vec<u8>, std::io::Error> {
631 rkyv::to_bytes::<rkyv::rancor::Error>(directives)
632 .map(|v| v.to_vec())
633 .map_err(|e| std::io::Error::other(e.to_string()))
634}
635
636#[cfg(test)]
638fn deserialize_directives(data: &[u8]) -> Option<Vec<Spanned<Directive>>> {
639 rkyv::from_bytes::<Vec<Spanned<Directive>>, rkyv::rancor::Error>(data).ok()
640}
641
642pub fn invalidate_cache(main_file: &Path) {
647 let cache_file = cache_path(main_file);
648 let _ = fs::remove_file(&cache_file);
649
650 let legacy = legacy_cache_path(main_file);
651 if legacy != cache_file {
652 let _ = fs::remove_file(&legacy);
653 }
654}
655
656#[cfg(test)]
657mod tests {
658 use super::*;
659 use crate::dedup::reintern_directives;
660 use rust_decimal_macros::dec;
661 use rustledger_core::{Amount, Posting, Transaction};
662 use rustledger_parser::Span;
663
664 #[test]
665 fn test_cache_header_roundtrip() {
666 let header = CacheHeader {
667 magic: *CACHE_MAGIC,
668 version: CACHE_VERSION,
669 hash: [42u8; 32],
670 data_len: 12345,
671 };
672
673 let bytes = header.to_bytes();
674 let parsed = CacheHeader::from_bytes(&bytes).unwrap();
675
676 assert_eq!(parsed.magic, header.magic);
677 assert_eq!(parsed.version, header.version);
678 assert_eq!(parsed.hash, header.hash);
679 assert_eq!(parsed.data_len, header.data_len);
680 }
681
682 #[test]
683 fn test_compute_hash_deterministic() {
684 let files: Vec<&Path> = vec![];
685 let hash1 = compute_hash(&files);
686 let hash2 = compute_hash(&files);
687 assert_eq!(hash1, hash2);
688 }
689
690 #[test]
691 fn test_serialize_deserialize_roundtrip() {
692 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
693
694 let txn = Transaction::new(date, "Test transaction")
695 .with_payee("Test Payee")
696 .with_synthesized_posting(Posting::new(
697 "Expenses:Test",
698 Amount::new(dec!(100.00), "USD"),
699 ))
700 .with_synthesized_posting(Posting::auto("Assets:Checking"));
701
702 let directives = vec![Spanned::new(Directive::Transaction(txn), Span::new(0, 100))];
703
704 let serialized = serialize_directives(&directives).expect("serialization failed");
706
707 let deserialized = deserialize_directives(&serialized).expect("deserialization failed");
709
710 assert_eq!(directives.len(), deserialized.len());
712 let orig_txn = directives[0].value.as_transaction().unwrap();
713 let deser_txn = deserialized[0].value.as_transaction().unwrap();
714
715 assert_eq!(orig_txn.date, deser_txn.date);
716 assert_eq!(orig_txn.payee, deser_txn.payee);
717 assert_eq!(orig_txn.narration, deser_txn.narration);
718 assert_eq!(orig_txn.postings.len(), deser_txn.postings.len());
719
720 assert_eq!(orig_txn.postings[0].account, deser_txn.postings[0].account);
722 assert_eq!(orig_txn.postings[0].units, deser_txn.postings[0].units);
723 }
724
725 #[test]
726 #[ignore = "manual benchmark - run with: cargo test -p rustledger-loader --release -- --ignored --nocapture"]
727 fn bench_cache_performance() {
728 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
730 let mut directives = Vec::with_capacity(10000);
731
732 for i in 0..10000 {
733 let txn = Transaction::new(date, format!("Transaction {i}"))
734 .with_payee("Store")
735 .with_synthesized_posting(Posting::new(
736 "Expenses:Food",
737 Amount::new(dec!(25.00), "USD"),
738 ))
739 .with_synthesized_posting(Posting::auto("Assets:Checking"));
740
741 directives.push(Spanned::new(Directive::Transaction(txn), Span::new(0, 100)));
742 }
743
744 println!("\n=== Cache Benchmark (10,000 directives) ===");
745
746 let start = std::time::Instant::now();
748 let serialized = serialize_directives(&directives).unwrap();
749 let serialize_time = start.elapsed();
750 println!(
751 "Serialize: {:?} ({:.2} MB)",
752 serialize_time,
753 serialized.len() as f64 / 1_000_000.0
754 );
755
756 let start = std::time::Instant::now();
758 let deserialized = deserialize_directives(&serialized).unwrap();
759 let deserialize_time = start.elapsed();
760 println!("Deserialize: {deserialize_time:?}");
761
762 assert_eq!(directives.len(), deserialized.len());
763
764 println!(
765 "\nSpeedup potential: If parsing takes 100ms, cache load would be {:.1}x faster",
766 100.0 / deserialize_time.as_millis() as f64
767 );
768 }
769
770 fn assert_clean_cache_env() {
781 for var in [CACHE_FILENAME_ENV, DISABLE_CACHE_ENV] {
782 assert!(
783 std::env::var_os(var).is_none(),
784 "unset {var} before running this test"
785 );
786 }
787 }
788
789 #[test]
790 fn test_resolve_cache_pattern_relative_with_substitution() {
791 let source = Path::new("/home/user/finances/main.beancount");
792 let resolved = resolve_cache_pattern(source, ".cache/{filename}.bin");
793 assert_eq!(
794 resolved,
795 Path::new("/home/user/finances/.cache/main.beancount.bin")
796 );
797 }
798
799 #[test]
800 fn test_resolve_cache_pattern_absolute() {
801 let source = Path::new("/home/user/main.beancount");
802 let resolved = resolve_cache_pattern(source, "/var/cache/rledger/{filename}.cache");
803 assert_eq!(
804 resolved,
805 Path::new("/var/cache/rledger/main.beancount.cache")
806 );
807 }
808
809 #[test]
810 fn test_resolve_cache_pattern_no_substitution() {
811 let source = Path::new("/home/user/main.beancount");
813 let resolved = resolve_cache_pattern(source, "fixed.cache");
814 assert_eq!(resolved, Path::new("/home/user/fixed.cache"));
815 }
816
817 #[test]
818 fn test_legacy_cache_path() {
819 let source = Path::new("/tmp/ledger.beancount");
820 assert_eq!(
821 legacy_cache_path(source),
822 Path::new("/tmp/ledger.beancount.cache")
823 );
824 }
825
826 #[test]
827 fn test_save_load_cache_entry_roundtrip() {
828 use std::io::Write;
829
830 assert_clean_cache_env();
831
832 let temp_dir = std::env::temp_dir().join("rustledger_cache_test");
834 let _ = fs::create_dir_all(&temp_dir);
835
836 let beancount_file = temp_dir.join("test.beancount");
838 let mut f = fs::File::create(&beancount_file).unwrap();
839 writeln!(f, "2024-01-01 open Assets:Test").unwrap();
840 drop(f);
841
842 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
844 let txn =
845 Transaction::new(date, "Test").with_synthesized_posting(Posting::auto("Assets:Test"));
846 let directives = vec![Spanned::new(Directive::Transaction(txn), Span::new(0, 50))];
847
848 let entry = CacheEntry {
849 directives,
850 options: CachedOptions::from(&Options::new()),
851 plugins: vec![CachedPlugin {
852 name: "test_plugin".to_string(),
853 config: Some("config".to_string()),
854 force_python: false,
855 }],
856 files: vec![beancount_file.to_string_lossy().to_string()],
857 };
858
859 save_cache_entry(&beancount_file, &entry).expect("save failed");
861
862 let loaded = load_cache_entry(&beancount_file).expect("load failed");
864
865 assert_eq!(loaded.directives.len(), entry.directives.len());
867 assert_eq!(loaded.plugins.len(), 1);
868 assert_eq!(loaded.plugins[0].name, "test_plugin");
869 assert_eq!(loaded.plugins[0].config, Some("config".to_string()));
870 assert_eq!(loaded.files.len(), 1);
871
872 let _ = fs::remove_file(&beancount_file);
874 let _ = fs::remove_file(cache_path(&beancount_file));
875 let _ = fs::remove_dir(&temp_dir);
876 }
877
878 #[test]
879 fn test_invalidate_cache() {
880 use std::io::Write;
881
882 assert_clean_cache_env();
883
884 let temp_dir = std::env::temp_dir().join("rustledger_invalidate_test");
885 let _ = fs::create_dir_all(&temp_dir);
886
887 let beancount_file = temp_dir.join("test.beancount");
888 let mut f = fs::File::create(&beancount_file).unwrap();
889 writeln!(f, "2024-01-01 open Assets:Test").unwrap();
890 drop(f);
891
892 let entry = CacheEntry {
894 directives: vec![],
895 options: CachedOptions::from(&Options::new()),
896 plugins: vec![],
897 files: vec![beancount_file.to_string_lossy().to_string()],
898 };
899 save_cache_entry(&beancount_file, &entry).unwrap();
900
901 assert!(cache_path(&beancount_file).exists());
903
904 invalidate_cache(&beancount_file);
906
907 assert!(!cache_path(&beancount_file).exists());
909
910 let _ = fs::remove_file(&beancount_file);
912 let _ = fs::remove_dir(&temp_dir);
913 }
914
915 #[test]
916 fn test_invalidate_cache_removes_legacy_sidecar() {
917 assert_clean_cache_env();
920
921 let temp_dir = std::env::temp_dir().join("rustledger_invalidate_legacy_test");
922 let _ = fs::create_dir_all(&temp_dir);
923
924 let beancount_file = temp_dir.join("legacy.beancount");
925 let legacy = legacy_cache_path(&beancount_file);
928 fs::write(&legacy, b"stale").unwrap();
929 assert!(legacy.exists());
930
931 invalidate_cache(&beancount_file);
932 assert!(
933 !legacy.exists(),
934 "invalidate_cache should remove the legacy sidecar file"
935 );
936
937 let _ = fs::remove_dir(&temp_dir);
938 }
939
940 #[test]
941 fn test_load_cache_missing_file() {
942 let missing = Path::new("/nonexistent/path/to/file.beancount");
943 assert!(load_cache_entry(missing).is_none());
944 }
945
946 #[test]
947 fn test_load_cache_invalid_magic() {
948 use std::io::Write;
949
950 assert_clean_cache_env();
951
952 let temp_dir = std::env::temp_dir().join("rustledger_magic_test");
953 let _ = fs::create_dir_all(&temp_dir);
954
955 let beancount_file = temp_dir.join("test.beancount");
956 let cache_file = cache_path(&beancount_file);
958 let mut f = fs::File::create(&cache_file).unwrap();
959 f.write_all(b"INVALID\0").unwrap();
961 f.write_all(&[0u8; CacheHeader::SIZE - 8]).unwrap();
962 drop(f);
963
964 assert!(load_cache_entry(&beancount_file).is_none());
965
966 let _ = fs::remove_file(&cache_file);
968 let _ = fs::remove_dir(&temp_dir);
969 }
970
971 #[test]
977 fn test_load_cache_rejects_older_version() {
978 use std::io::Write;
979
980 assert_clean_cache_env();
981
982 let temp_dir = std::env::temp_dir().join("rustledger_old_version_test");
983 let _ = fs::create_dir_all(&temp_dir);
984
985 let beancount_file = temp_dir.join("test.beancount");
986 let cache_file = cache_path(&beancount_file);
987 let mut f = fs::File::create(&cache_file).unwrap();
988
989 let stale_version: u32 = CACHE_VERSION.checked_sub(1).expect("CACHE_VERSION >= 1");
993 f.write_all(CACHE_MAGIC).unwrap();
994 f.write_all(&stale_version.to_le_bytes()).unwrap();
995 f.write_all(&[0u8; CacheHeader::SIZE - 8 - 4]).unwrap();
996 drop(f);
997
998 assert!(
999 load_cache_entry(&beancount_file).is_none(),
1000 "loader must reject cache files with an older CACHE_VERSION"
1001 );
1002
1003 let _ = fs::remove_file(&cache_file);
1004 let _ = fs::remove_dir(&temp_dir);
1005 }
1006
1007 #[cfg(target_endian = "little")]
1040 #[test]
1041 fn cost_number_archived_bytes_match_v8_fixtures() {
1042 use rust_decimal_macros::dec;
1043 use rustledger_core::{BookedCost, CostNumber};
1044
1045 const FIXTURE_VERSION: u32 = 14;
1060 assert_eq!(
1061 CACHE_VERSION, FIXTURE_VERSION,
1062 "CACHE_VERSION advanced past the fixture version; regenerate \
1063 the byte fixtures in this test and update FIXTURE_VERSION, \
1064 or remove the tripwire if v{CACHE_VERSION}'s CostNumber \
1065 encoding is byte-identical to the fixtures.",
1066 );
1067
1068 let cases: &[(&str, CostNumber, &[u8])] = &[
1069 (
1070 "PerUnit { value: 150 }",
1071 CostNumber::PerUnit { value: dec!(150) },
1072 &[
1073 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,
1074 0, 0, 0, 0, 0, 0, 0,
1075 ],
1076 ),
1077 (
1078 "Compound { per_unit: 5, total: 10 }",
1079 CostNumber::Compound {
1080 per_unit: dec!(5),
1081 total: dec!(10),
1082 },
1083 &[
1084 3, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 0, 0, 0, 0,
1085 0, 0, 0, 0, 0, 0, 0,
1086 ],
1087 ),
1088 (
1089 "Total { value: 1500 }",
1090 CostNumber::Total { value: dec!(1500) },
1091 &[
1092 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,
1093 0, 0, 0, 0, 0, 0, 0,
1094 ],
1095 ),
1096 (
1097 "PerUnitFromTotal { per_unit: 150, total: 300 }",
1098 CostNumber::PerUnitFromTotal(BookedCost::new(dec!(150), dec!(300), dec!(2))),
1099 &[
1100 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,
1101 0, 0, 0, 0, 0, 0, 0, 0,
1102 ],
1103 ),
1104 ];
1105 let mut mismatches = Vec::new();
1106 for (name, cn, expected) in cases {
1107 let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(cn).unwrap();
1108 if bytes.as_ref() != *expected {
1109 mismatches.push(format!(" `{name}` → {:?}", bytes.as_ref()));
1110 }
1111 }
1112 assert!(
1113 mismatches.is_empty(),
1114 "rkyv layout drifted from v8 fixtures — bump CACHE_VERSION and \
1115 update the fixtures in this test if intentional. Actual bytes:\n{}",
1116 mismatches.join("\n"),
1117 );
1118 }
1119
1120 #[cfg(target_endian = "little")]
1137 #[test]
1138 fn meta_value_archived_layout_hash_matches() {
1139 use rustledger_core::{Account, Currency, Link, MetaValue, Tag};
1140
1141 const FIXTURE_VERSION: u32 = 14;
1147 const META_VALUE_LAYOUT_HASH: &str =
1148 "43e3c258fe376cede6a6c2c975100bcf67ddda0ab84b21566b123c01e0a54b25";
1149 assert_eq!(
1150 CACHE_VERSION, FIXTURE_VERSION,
1151 "CACHE_VERSION advanced past the MetaValue layout-hash fixture; if the \
1152 MetaValue archived layout changed, bump CACHE_VERSION and regenerate \
1153 META_VALUE_LAYOUT_HASH below in the same commit, else just bump \
1154 FIXTURE_VERSION.",
1155 );
1156
1157 let variants: &[MetaValue] = &[
1160 MetaValue::String("USD".to_string()),
1161 MetaValue::Account(Account::from("Assets:Bank")),
1162 MetaValue::Currency(Currency::from("USD")),
1163 MetaValue::Tag(Tag::from("t")),
1164 MetaValue::Link(Link::from("t")),
1165 MetaValue::Date(rustledger_core::naive_date(2024, 1, 15).unwrap()),
1166 MetaValue::Number(dec!(42)),
1167 MetaValue::Bool(true),
1168 MetaValue::Amount(Amount::new(dec!(10), "USD")),
1169 MetaValue::None,
1170 MetaValue::Int(42),
1171 ];
1172
1173 let mut hasher = Hasher::new();
1174 for mv in variants {
1175 let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(mv).unwrap();
1176 hasher.update(&(bytes.len() as u64).to_le_bytes());
1179 hasher.update(&bytes);
1180 }
1181 let digest = hasher.finalize().to_hex();
1182
1183 assert_eq!(
1184 digest.as_str(),
1185 META_VALUE_LAYOUT_HASH,
1186 "MetaValue archived layout changed. If intentional, bump CACHE_VERSION \
1187 and set META_VALUE_LAYOUT_HASH to: {digest}",
1188 );
1189 }
1190
1191 #[test]
1192 fn test_reintern_directives_deduplication() {
1193 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1194
1195 let mut directives = vec![];
1197 for i in 0..5 {
1198 let txn = Transaction::new(date, format!("Txn {i}"))
1199 .with_synthesized_posting(Posting::new(
1200 "Expenses:Food",
1201 Amount::new(dec!(10.00), "USD"),
1202 ))
1203 .with_synthesized_posting(Posting::auto("Assets:Checking"));
1204 directives.push(Spanned::new(Directive::Transaction(txn), Span::new(0, 50)));
1205 }
1206
1207 let dedup_count = reintern_directives(&mut directives);
1209
1210 assert_eq!(dedup_count, 12);
1216 }
1217
1218 #[test]
1219 fn test_cached_options_roundtrip() {
1220 let mut opts = Options::new();
1221 opts.title = Some("Test Ledger".to_string());
1222 opts.operating_currency = vec!["USD".to_string(), "EUR".to_string()];
1223 opts.render_commas = true;
1224
1225 let cached = CachedOptions::from(&opts);
1226 let restored: Options = cached.into();
1227
1228 assert_eq!(restored.title, Some("Test Ledger".to_string()));
1229 assert_eq!(restored.operating_currency, vec!["USD", "EUR"]);
1230 assert!(restored.render_commas);
1231 }
1232
1233 #[test]
1244 fn cached_options_field_parity() {
1245 use rust_decimal_macros::dec;
1246
1247 let mut opts = Options::new();
1248 opts.title = Some("T".into());
1249 opts.filename = Some("f.beancount".into());
1250 opts.operating_currency = vec!["USD".into(), "EUR".into()];
1251 opts.name_assets = "A".into();
1252 opts.name_liabilities = "L".into();
1253 opts.name_equity = "Q".into();
1254 opts.name_income = "I".into();
1255 opts.name_expenses = "X".into();
1256 opts.account_rounding = Some("Equity:Round".into());
1257 opts.account_previous_balances = "Opening".into();
1258 opts.account_previous_earnings = "Earn".into();
1259 opts.account_previous_conversions = "Conv".into();
1260 opts.account_current_earnings = "CurEarn".into();
1261 opts.account_current_conversions = Some("CurConv".into());
1262 opts.account_unrealized_gains = Some("Unreal".into());
1263 opts.conversion_currency = Some("NOTHING".into());
1264 opts.inferred_tolerance_default =
1265 std::iter::once(("USD".to_string(), dec!(0.005))).collect();
1266 opts.inferred_tolerance_multiplier = dec!(1.5);
1267 opts.infer_tolerance_from_cost = true;
1268 opts.use_legacy_fixed_tolerances = true;
1269 opts.experiment_explicit_tolerances = true;
1270 opts.use_precise_interpolation = true;
1271 opts.booking_method = "FIFO".into();
1272 opts.render_commas = true;
1273 opts.display_precision = [("USD".to_string(), 4u32), ("JPY".to_string(), 0)]
1274 .into_iter()
1275 .collect();
1276 opts.allow_pipe_separator = true;
1277 opts.long_string_maxlines = 99;
1278 opts.documents = vec!["docs".into()];
1279 opts.plugin_processing_mode = "raw".into();
1280 opts.custom = std::iter::once(("k".to_string(), "v".to_string())).collect();
1281 opts.set_options = std::iter::once("booking_method".to_string()).collect();
1282 let restored: Options = CachedOptions::from(&opts).into();
1285 assert_eq!(
1286 restored, opts,
1287 "a CachedOptions field was dropped on the cache round-trip"
1288 );
1289 }
1290
1291 #[test]
1296 fn test_cached_options_preserves_set_options_for_booking_method() {
1297 let mut opts = Options::new();
1298 opts.set("booking_method", "FIFO");
1301 assert!(opts.set_options.contains("booking_method"));
1302
1303 let cached = CachedOptions::from(&opts);
1304 let restored: Options = cached.into();
1305
1306 assert_eq!(restored.booking_method, "FIFO");
1307 assert!(
1308 restored.set_options.contains("booking_method"),
1309 "set_options dropped across cache round-trip — booking method \
1310 resolution would fall back to the STRICT default on a cache hit"
1311 );
1312 }
1313
1314 #[test]
1315 fn test_cache_entry_file_paths() {
1316 let entry = CacheEntry {
1317 directives: vec![],
1318 options: CachedOptions::from(&Options::new()),
1319 plugins: vec![],
1320 files: vec![
1321 "/path/to/ledger.beancount".to_string(),
1322 "/path/to/include.beancount".to_string(),
1323 ],
1324 };
1325
1326 let paths = entry.file_paths();
1327 assert_eq!(paths.len(), 2);
1328 assert_eq!(paths[0], PathBuf::from("/path/to/ledger.beancount"));
1329 assert_eq!(paths[1], PathBuf::from("/path/to/include.beancount"));
1330 }
1331
1332 #[test]
1333 fn test_reintern_balance_directive() {
1334 use rustledger_core::Balance;
1335
1336 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1337 let balance = Balance::new(date, "Assets:Checking", Amount::new(dec!(1000.00), "USD"));
1338
1339 let mut directives = vec![
1340 Spanned::new(Directive::Balance(balance.clone()), Span::new(0, 50)),
1341 Spanned::new(Directive::Balance(balance), Span::new(51, 100)),
1342 ];
1343
1344 let dedup_count = reintern_directives(&mut directives);
1345 assert_eq!(dedup_count, 2);
1347 }
1348
1349 #[test]
1350 fn test_reintern_open_close_directives() {
1351 use rustledger_core::{Close, Open};
1352
1353 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1354 let open = Open::new(date, "Assets:Checking");
1355 let close = Close::new(date, "Assets:Checking");
1356
1357 let mut directives = vec![
1358 Spanned::new(Directive::Open(open), Span::new(0, 50)),
1359 Spanned::new(Directive::Close(close), Span::new(51, 100)),
1360 ];
1361
1362 let dedup_count = reintern_directives(&mut directives);
1363 assert_eq!(dedup_count, 1);
1365 }
1366}