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 = 12;
347
348#[derive(Debug, Clone)]
350struct CacheHeader {
351 magic: [u8; 8],
353 version: u32,
355 hash: [u8; 32],
357 data_len: u64,
359}
360
361impl CacheHeader {
362 const SIZE: usize = 8 + 4 + 32 + 8;
363
364 fn to_bytes(&self) -> [u8; Self::SIZE] {
365 let mut buf = [0u8; Self::SIZE];
366 buf[0..8].copy_from_slice(&self.magic);
367 buf[8..12].copy_from_slice(&self.version.to_le_bytes());
368 buf[12..44].copy_from_slice(&self.hash);
369 buf[44..52].copy_from_slice(&self.data_len.to_le_bytes());
370 buf
371 }
372
373 fn from_bytes(bytes: &[u8]) -> Option<Self> {
374 if bytes.len() < Self::SIZE {
375 return None;
376 }
377
378 let mut magic = [0u8; 8];
379 magic.copy_from_slice(&bytes[0..8]);
380
381 let version = u32::from_le_bytes(bytes[8..12].try_into().ok()?);
382
383 let mut hash = [0u8; 32];
384 hash.copy_from_slice(&bytes[12..44]);
385
386 let data_len = u64::from_le_bytes(bytes[44..52].try_into().ok()?);
387
388 Some(Self {
389 magic,
390 version,
391 hash,
392 data_len,
393 })
394 }
395}
396
397fn compute_hash(files: &[&Path]) -> [u8; 32] {
403 let mut hasher = Hasher::new();
404
405 for file in files {
406 hasher.update(file.to_string_lossy().as_bytes());
408
409 if let Ok(metadata) = fs::metadata(file) {
411 if let Ok(mtime) = metadata.modified()
412 && let Ok(duration) = mtime.duration_since(std::time::UNIX_EPOCH)
413 {
414 hasher.update(&duration.as_secs().to_le_bytes());
415 hasher.update(&duration.subsec_nanos().to_le_bytes());
416 }
417 hasher.update(&metadata.len().to_le_bytes());
419 }
420 }
421
422 *hasher.finalize().as_bytes()
423}
424
425pub const CACHE_FILENAME_ENV: &str = "BEANCOUNT_LOAD_CACHE_FILENAME";
432
433pub const DISABLE_CACHE_ENV: &str = "BEANCOUNT_DISABLE_LOAD_CACHE";
437
438pub fn cache_path(source: &Path) -> PathBuf {
456 if let Ok(pattern) = std::env::var(CACHE_FILENAME_ENV)
457 && !pattern.is_empty()
458 {
459 return resolve_cache_pattern(source, &pattern);
460 }
461 default_cache_path(source)
462}
463
464#[must_use]
470pub fn default_cache_path(source: &Path) -> PathBuf {
471 let mut path = source.to_path_buf();
472 let name = path.file_name().map_or_else(
473 || ".ledger.cache".to_string(),
474 |n| format!(".{}.cache", n.to_string_lossy()),
475 );
476 path.set_file_name(name);
477 path
478}
479
480#[allow(clippy::literal_string_with_formatting_args)]
486fn resolve_cache_pattern(source: &Path, pattern: &str) -> PathBuf {
487 let filename = source.file_name().map_or_else(
488 || "ledger".to_string(),
489 |n| n.to_string_lossy().into_owned(),
490 );
491 let resolved = pattern.replace("{filename}", &filename);
492 let p = PathBuf::from(&resolved);
493 if p.is_absolute() {
494 return p;
495 }
496 source.parent().map_or(p.clone(), |parent| parent.join(&p))
497}
498
499fn legacy_cache_path(source: &Path) -> PathBuf {
504 let mut path = source.to_path_buf();
505 let name = path.file_name().map_or_else(
506 || "ledger.cache".to_string(),
507 |n| format!("{}.cache", n.to_string_lossy()),
508 );
509 path.set_file_name(name);
510 path
511}
512
513#[must_use]
519pub fn cache_disabled_by_env() -> bool {
520 std::env::var_os(DISABLE_CACHE_ENV).is_some()
521}
522
523pub fn load_cache_entry(main_file: &Path) -> Option<CacheEntry> {
529 if cache_disabled_by_env() {
530 return None;
531 }
532 let cache_file = cache_path(main_file);
533 let mut file = fs::File::open(&cache_file).ok()?;
534
535 let mut header_bytes = [0u8; CacheHeader::SIZE];
537 file.read_exact(&mut header_bytes).ok()?;
538 let header = CacheHeader::from_bytes(&header_bytes)?;
539
540 if header.magic != *CACHE_MAGIC {
542 return None;
543 }
544 if header.version != CACHE_VERSION {
545 return None;
546 }
547
548 let mut data = vec![0u8; header.data_len as usize];
550 file.read_exact(&mut data).ok()?;
551
552 let entry: CacheEntry = rkyv::from_bytes::<CacheEntry, rkyv::rancor::Error>(&data).ok()?;
554
555 let file_paths = entry.file_paths();
557 let file_refs: Vec<&Path> = file_paths.iter().map(PathBuf::as_path).collect();
558 let expected_hash = compute_hash(&file_refs);
559 if header.hash != expected_hash {
560 return None;
561 }
562
563 Some(entry)
564}
565
566pub fn save_cache_entry(main_file: &Path, entry: &CacheEntry) -> Result<(), std::io::Error> {
570 if cache_disabled_by_env() {
571 return Ok(());
572 }
573 let cache_file = cache_path(main_file);
574
575 let file_paths = entry.file_paths();
577 let file_refs: Vec<&Path> = file_paths.iter().map(PathBuf::as_path).collect();
578 let hash = compute_hash(&file_refs);
579
580 let data = rkyv::to_bytes::<rkyv::rancor::Error>(entry)
582 .map(|v| v.to_vec())
583 .map_err(|e| std::io::Error::other(e.to_string()))?;
584
585 let header = CacheHeader {
587 magic: *CACHE_MAGIC,
588 version: CACHE_VERSION,
589 hash,
590 data_len: data.len() as u64,
591 };
592
593 if let Some(parent) = cache_file.parent()
597 && !parent.as_os_str().is_empty()
598 {
599 fs::create_dir_all(parent)?;
600 }
601
602 let mut file = fs::File::create(&cache_file)?;
603 file.write_all(&header.to_bytes())?;
604 file.write_all(&data)?;
605
606 let legacy = legacy_cache_path(main_file);
611 if legacy != cache_file && legacy.exists() {
612 let _ = fs::remove_file(&legacy);
613 }
614
615 Ok(())
616}
617
618#[cfg(test)]
620fn serialize_directives(directives: &Vec<Spanned<Directive>>) -> Result<Vec<u8>, std::io::Error> {
621 rkyv::to_bytes::<rkyv::rancor::Error>(directives)
622 .map(|v| v.to_vec())
623 .map_err(|e| std::io::Error::other(e.to_string()))
624}
625
626#[cfg(test)]
628fn deserialize_directives(data: &[u8]) -> Option<Vec<Spanned<Directive>>> {
629 rkyv::from_bytes::<Vec<Spanned<Directive>>, rkyv::rancor::Error>(data).ok()
630}
631
632pub fn invalidate_cache(main_file: &Path) {
637 let cache_file = cache_path(main_file);
638 let _ = fs::remove_file(&cache_file);
639
640 let legacy = legacy_cache_path(main_file);
641 if legacy != cache_file {
642 let _ = fs::remove_file(&legacy);
643 }
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649 use crate::dedup::reintern_directives;
650 use rust_decimal_macros::dec;
651 use rustledger_core::{Amount, Posting, Transaction};
652 use rustledger_parser::Span;
653
654 #[test]
655 fn test_cache_header_roundtrip() {
656 let header = CacheHeader {
657 magic: *CACHE_MAGIC,
658 version: CACHE_VERSION,
659 hash: [42u8; 32],
660 data_len: 12345,
661 };
662
663 let bytes = header.to_bytes();
664 let parsed = CacheHeader::from_bytes(&bytes).unwrap();
665
666 assert_eq!(parsed.magic, header.magic);
667 assert_eq!(parsed.version, header.version);
668 assert_eq!(parsed.hash, header.hash);
669 assert_eq!(parsed.data_len, header.data_len);
670 }
671
672 #[test]
673 fn test_compute_hash_deterministic() {
674 let files: Vec<&Path> = vec![];
675 let hash1 = compute_hash(&files);
676 let hash2 = compute_hash(&files);
677 assert_eq!(hash1, hash2);
678 }
679
680 #[test]
681 fn test_serialize_deserialize_roundtrip() {
682 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
683
684 let txn = Transaction::new(date, "Test transaction")
685 .with_payee("Test Payee")
686 .with_synthesized_posting(Posting::new(
687 "Expenses:Test",
688 Amount::new(dec!(100.00), "USD"),
689 ))
690 .with_synthesized_posting(Posting::auto("Assets:Checking"));
691
692 let directives = vec![Spanned::new(Directive::Transaction(txn), Span::new(0, 100))];
693
694 let serialized = serialize_directives(&directives).expect("serialization failed");
696
697 let deserialized = deserialize_directives(&serialized).expect("deserialization failed");
699
700 assert_eq!(directives.len(), deserialized.len());
702 let orig_txn = directives[0].value.as_transaction().unwrap();
703 let deser_txn = deserialized[0].value.as_transaction().unwrap();
704
705 assert_eq!(orig_txn.date, deser_txn.date);
706 assert_eq!(orig_txn.payee, deser_txn.payee);
707 assert_eq!(orig_txn.narration, deser_txn.narration);
708 assert_eq!(orig_txn.postings.len(), deser_txn.postings.len());
709
710 assert_eq!(orig_txn.postings[0].account, deser_txn.postings[0].account);
712 assert_eq!(orig_txn.postings[0].units, deser_txn.postings[0].units);
713 }
714
715 #[test]
716 #[ignore = "manual benchmark - run with: cargo test -p rustledger-loader --release -- --ignored --nocapture"]
717 fn bench_cache_performance() {
718 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
720 let mut directives = Vec::with_capacity(10000);
721
722 for i in 0..10000 {
723 let txn = Transaction::new(date, format!("Transaction {i}"))
724 .with_payee("Store")
725 .with_synthesized_posting(Posting::new(
726 "Expenses:Food",
727 Amount::new(dec!(25.00), "USD"),
728 ))
729 .with_synthesized_posting(Posting::auto("Assets:Checking"));
730
731 directives.push(Spanned::new(Directive::Transaction(txn), Span::new(0, 100)));
732 }
733
734 println!("\n=== Cache Benchmark (10,000 directives) ===");
735
736 let start = std::time::Instant::now();
738 let serialized = serialize_directives(&directives).unwrap();
739 let serialize_time = start.elapsed();
740 println!(
741 "Serialize: {:?} ({:.2} MB)",
742 serialize_time,
743 serialized.len() as f64 / 1_000_000.0
744 );
745
746 let start = std::time::Instant::now();
748 let deserialized = deserialize_directives(&serialized).unwrap();
749 let deserialize_time = start.elapsed();
750 println!("Deserialize: {deserialize_time:?}");
751
752 assert_eq!(directives.len(), deserialized.len());
753
754 println!(
755 "\nSpeedup potential: If parsing takes 100ms, cache load would be {:.1}x faster",
756 100.0 / deserialize_time.as_millis() as f64
757 );
758 }
759
760 fn assert_clean_cache_env() {
771 for var in [CACHE_FILENAME_ENV, DISABLE_CACHE_ENV] {
772 assert!(
773 std::env::var_os(var).is_none(),
774 "unset {var} before running this test"
775 );
776 }
777 }
778
779 #[test]
780 fn test_resolve_cache_pattern_relative_with_substitution() {
781 let source = Path::new("/home/user/finances/main.beancount");
782 let resolved = resolve_cache_pattern(source, ".cache/{filename}.bin");
783 assert_eq!(
784 resolved,
785 Path::new("/home/user/finances/.cache/main.beancount.bin")
786 );
787 }
788
789 #[test]
790 fn test_resolve_cache_pattern_absolute() {
791 let source = Path::new("/home/user/main.beancount");
792 let resolved = resolve_cache_pattern(source, "/var/cache/rledger/{filename}.cache");
793 assert_eq!(
794 resolved,
795 Path::new("/var/cache/rledger/main.beancount.cache")
796 );
797 }
798
799 #[test]
800 fn test_resolve_cache_pattern_no_substitution() {
801 let source = Path::new("/home/user/main.beancount");
803 let resolved = resolve_cache_pattern(source, "fixed.cache");
804 assert_eq!(resolved, Path::new("/home/user/fixed.cache"));
805 }
806
807 #[test]
808 fn test_legacy_cache_path() {
809 let source = Path::new("/tmp/ledger.beancount");
810 assert_eq!(
811 legacy_cache_path(source),
812 Path::new("/tmp/ledger.beancount.cache")
813 );
814 }
815
816 #[test]
817 fn test_save_load_cache_entry_roundtrip() {
818 use std::io::Write;
819
820 assert_clean_cache_env();
821
822 let temp_dir = std::env::temp_dir().join("rustledger_cache_test");
824 let _ = fs::create_dir_all(&temp_dir);
825
826 let beancount_file = temp_dir.join("test.beancount");
828 let mut f = fs::File::create(&beancount_file).unwrap();
829 writeln!(f, "2024-01-01 open Assets:Test").unwrap();
830 drop(f);
831
832 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
834 let txn =
835 Transaction::new(date, "Test").with_synthesized_posting(Posting::auto("Assets:Test"));
836 let directives = vec![Spanned::new(Directive::Transaction(txn), Span::new(0, 50))];
837
838 let entry = CacheEntry {
839 directives,
840 options: CachedOptions::from(&Options::new()),
841 plugins: vec![CachedPlugin {
842 name: "test_plugin".to_string(),
843 config: Some("config".to_string()),
844 force_python: false,
845 }],
846 files: vec![beancount_file.to_string_lossy().to_string()],
847 };
848
849 save_cache_entry(&beancount_file, &entry).expect("save failed");
851
852 let loaded = load_cache_entry(&beancount_file).expect("load failed");
854
855 assert_eq!(loaded.directives.len(), entry.directives.len());
857 assert_eq!(loaded.plugins.len(), 1);
858 assert_eq!(loaded.plugins[0].name, "test_plugin");
859 assert_eq!(loaded.plugins[0].config, Some("config".to_string()));
860 assert_eq!(loaded.files.len(), 1);
861
862 let _ = fs::remove_file(&beancount_file);
864 let _ = fs::remove_file(cache_path(&beancount_file));
865 let _ = fs::remove_dir(&temp_dir);
866 }
867
868 #[test]
869 fn test_invalidate_cache() {
870 use std::io::Write;
871
872 assert_clean_cache_env();
873
874 let temp_dir = std::env::temp_dir().join("rustledger_invalidate_test");
875 let _ = fs::create_dir_all(&temp_dir);
876
877 let beancount_file = temp_dir.join("test.beancount");
878 let mut f = fs::File::create(&beancount_file).unwrap();
879 writeln!(f, "2024-01-01 open Assets:Test").unwrap();
880 drop(f);
881
882 let entry = CacheEntry {
884 directives: vec![],
885 options: CachedOptions::from(&Options::new()),
886 plugins: vec![],
887 files: vec![beancount_file.to_string_lossy().to_string()],
888 };
889 save_cache_entry(&beancount_file, &entry).unwrap();
890
891 assert!(cache_path(&beancount_file).exists());
893
894 invalidate_cache(&beancount_file);
896
897 assert!(!cache_path(&beancount_file).exists());
899
900 let _ = fs::remove_file(&beancount_file);
902 let _ = fs::remove_dir(&temp_dir);
903 }
904
905 #[test]
906 fn test_invalidate_cache_removes_legacy_sidecar() {
907 assert_clean_cache_env();
910
911 let temp_dir = std::env::temp_dir().join("rustledger_invalidate_legacy_test");
912 let _ = fs::create_dir_all(&temp_dir);
913
914 let beancount_file = temp_dir.join("legacy.beancount");
915 let legacy = legacy_cache_path(&beancount_file);
918 fs::write(&legacy, b"stale").unwrap();
919 assert!(legacy.exists());
920
921 invalidate_cache(&beancount_file);
922 assert!(
923 !legacy.exists(),
924 "invalidate_cache should remove the legacy sidecar file"
925 );
926
927 let _ = fs::remove_dir(&temp_dir);
928 }
929
930 #[test]
931 fn test_load_cache_missing_file() {
932 let missing = Path::new("/nonexistent/path/to/file.beancount");
933 assert!(load_cache_entry(missing).is_none());
934 }
935
936 #[test]
937 fn test_load_cache_invalid_magic() {
938 use std::io::Write;
939
940 assert_clean_cache_env();
941
942 let temp_dir = std::env::temp_dir().join("rustledger_magic_test");
943 let _ = fs::create_dir_all(&temp_dir);
944
945 let beancount_file = temp_dir.join("test.beancount");
946 let cache_file = cache_path(&beancount_file);
948 let mut f = fs::File::create(&cache_file).unwrap();
949 f.write_all(b"INVALID\0").unwrap();
951 f.write_all(&[0u8; CacheHeader::SIZE - 8]).unwrap();
952 drop(f);
953
954 assert!(load_cache_entry(&beancount_file).is_none());
955
956 let _ = fs::remove_file(&cache_file);
958 let _ = fs::remove_dir(&temp_dir);
959 }
960
961 #[test]
967 fn test_load_cache_rejects_older_version() {
968 use std::io::Write;
969
970 assert_clean_cache_env();
971
972 let temp_dir = std::env::temp_dir().join("rustledger_old_version_test");
973 let _ = fs::create_dir_all(&temp_dir);
974
975 let beancount_file = temp_dir.join("test.beancount");
976 let cache_file = cache_path(&beancount_file);
977 let mut f = fs::File::create(&cache_file).unwrap();
978
979 let stale_version: u32 = CACHE_VERSION.checked_sub(1).expect("CACHE_VERSION >= 1");
983 f.write_all(CACHE_MAGIC).unwrap();
984 f.write_all(&stale_version.to_le_bytes()).unwrap();
985 f.write_all(&[0u8; CacheHeader::SIZE - 8 - 4]).unwrap();
986 drop(f);
987
988 assert!(
989 load_cache_entry(&beancount_file).is_none(),
990 "loader must reject cache files with an older CACHE_VERSION"
991 );
992
993 let _ = fs::remove_file(&cache_file);
994 let _ = fs::remove_dir(&temp_dir);
995 }
996
997 #[cfg(target_endian = "little")]
1030 #[test]
1031 fn cost_number_archived_bytes_match_v8_fixtures() {
1032 use rust_decimal_macros::dec;
1033 use rustledger_core::{BookedCost, CostNumber};
1034
1035 const FIXTURE_VERSION: u32 = 12;
1046 assert_eq!(
1047 CACHE_VERSION, FIXTURE_VERSION,
1048 "CACHE_VERSION advanced past the fixture version; regenerate \
1049 the byte fixtures in this test and update FIXTURE_VERSION, \
1050 or remove the tripwire if v{CACHE_VERSION}'s CostNumber \
1051 encoding is byte-identical to the fixtures.",
1052 );
1053
1054 let cases: &[(&str, CostNumber, &[u8])] = &[
1055 (
1056 "PerUnit { value: 150 }",
1057 CostNumber::PerUnit { value: dec!(150) },
1058 &[
1059 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,
1060 0, 0, 0, 0, 0, 0, 0,
1061 ],
1062 ),
1063 (
1064 "Total { value: 1500 }",
1065 CostNumber::Total { value: dec!(1500) },
1066 &[
1067 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,
1068 0, 0, 0, 0, 0, 0, 0,
1069 ],
1070 ),
1071 (
1072 "PerUnitFromTotal { per_unit: 150, total: 300 }",
1073 CostNumber::PerUnitFromTotal(BookedCost::new(dec!(150), dec!(300), dec!(2))),
1074 &[
1075 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,
1076 0, 0, 0, 0, 0, 0, 0, 0,
1077 ],
1078 ),
1079 ];
1080 let mut mismatches = Vec::new();
1081 for (name, cn, expected) in cases {
1082 let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(cn).unwrap();
1083 if bytes.as_ref() != *expected {
1084 mismatches.push(format!(" `{name}` → {:?}", bytes.as_ref()));
1085 }
1086 }
1087 assert!(
1088 mismatches.is_empty(),
1089 "rkyv layout drifted from v8 fixtures — bump CACHE_VERSION and \
1090 update the fixtures in this test if intentional. Actual bytes:\n{}",
1091 mismatches.join("\n"),
1092 );
1093 }
1094
1095 #[cfg(target_endian = "little")]
1112 #[test]
1113 fn meta_value_archived_layout_hash_matches() {
1114 use rustledger_core::{Account, Currency, Link, MetaValue, Tag};
1115
1116 const FIXTURE_VERSION: u32 = 12;
1119 const META_VALUE_LAYOUT_HASH: &str =
1120 "43e3c258fe376cede6a6c2c975100bcf67ddda0ab84b21566b123c01e0a54b25";
1121 assert_eq!(
1122 CACHE_VERSION, FIXTURE_VERSION,
1123 "CACHE_VERSION advanced past the MetaValue layout-hash fixture; if the \
1124 MetaValue archived layout changed, bump CACHE_VERSION and regenerate \
1125 META_VALUE_LAYOUT_HASH below in the same commit, else just bump \
1126 FIXTURE_VERSION.",
1127 );
1128
1129 let variants: &[MetaValue] = &[
1132 MetaValue::String("USD".to_string()),
1133 MetaValue::Account(Account::from("Assets:Bank")),
1134 MetaValue::Currency(Currency::from("USD")),
1135 MetaValue::Tag(Tag::from("t")),
1136 MetaValue::Link(Link::from("t")),
1137 MetaValue::Date(rustledger_core::naive_date(2024, 1, 15).unwrap()),
1138 MetaValue::Number(dec!(42)),
1139 MetaValue::Bool(true),
1140 MetaValue::Amount(Amount::new(dec!(10), "USD")),
1141 MetaValue::None,
1142 MetaValue::Int(42),
1143 ];
1144
1145 let mut hasher = Hasher::new();
1146 for mv in variants {
1147 let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(mv).unwrap();
1148 hasher.update(&(bytes.len() as u64).to_le_bytes());
1151 hasher.update(&bytes);
1152 }
1153 let digest = hasher.finalize().to_hex();
1154
1155 assert_eq!(
1156 digest.as_str(),
1157 META_VALUE_LAYOUT_HASH,
1158 "MetaValue archived layout changed. If intentional, bump CACHE_VERSION \
1159 and set META_VALUE_LAYOUT_HASH to: {digest}",
1160 );
1161 }
1162
1163 #[test]
1164 fn test_reintern_directives_deduplication() {
1165 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1166
1167 let mut directives = vec![];
1169 for i in 0..5 {
1170 let txn = Transaction::new(date, format!("Txn {i}"))
1171 .with_synthesized_posting(Posting::new(
1172 "Expenses:Food",
1173 Amount::new(dec!(10.00), "USD"),
1174 ))
1175 .with_synthesized_posting(Posting::auto("Assets:Checking"));
1176 directives.push(Spanned::new(Directive::Transaction(txn), Span::new(0, 50)));
1177 }
1178
1179 let dedup_count = reintern_directives(&mut directives);
1181
1182 assert_eq!(dedup_count, 12);
1188 }
1189
1190 #[test]
1191 fn test_cached_options_roundtrip() {
1192 let mut opts = Options::new();
1193 opts.title = Some("Test Ledger".to_string());
1194 opts.operating_currency = vec!["USD".to_string(), "EUR".to_string()];
1195 opts.render_commas = true;
1196
1197 let cached = CachedOptions::from(&opts);
1198 let restored: Options = cached.into();
1199
1200 assert_eq!(restored.title, Some("Test Ledger".to_string()));
1201 assert_eq!(restored.operating_currency, vec!["USD", "EUR"]);
1202 assert!(restored.render_commas);
1203 }
1204
1205 #[test]
1216 fn cached_options_field_parity() {
1217 use rust_decimal_macros::dec;
1218
1219 let mut opts = Options::new();
1220 opts.title = Some("T".into());
1221 opts.filename = Some("f.beancount".into());
1222 opts.operating_currency = vec!["USD".into(), "EUR".into()];
1223 opts.name_assets = "A".into();
1224 opts.name_liabilities = "L".into();
1225 opts.name_equity = "Q".into();
1226 opts.name_income = "I".into();
1227 opts.name_expenses = "X".into();
1228 opts.account_rounding = Some("Equity:Round".into());
1229 opts.account_previous_balances = "Opening".into();
1230 opts.account_previous_earnings = "Earn".into();
1231 opts.account_previous_conversions = "Conv".into();
1232 opts.account_current_earnings = "CurEarn".into();
1233 opts.account_current_conversions = Some("CurConv".into());
1234 opts.account_unrealized_gains = Some("Unreal".into());
1235 opts.conversion_currency = Some("NOTHING".into());
1236 opts.inferred_tolerance_default =
1237 std::iter::once(("USD".to_string(), dec!(0.005))).collect();
1238 opts.inferred_tolerance_multiplier = dec!(1.5);
1239 opts.infer_tolerance_from_cost = true;
1240 opts.use_legacy_fixed_tolerances = true;
1241 opts.experiment_explicit_tolerances = true;
1242 opts.use_precise_interpolation = true;
1243 opts.booking_method = "FIFO".into();
1244 opts.render_commas = true;
1245 opts.display_precision = [("USD".to_string(), 4u32), ("JPY".to_string(), 0)]
1246 .into_iter()
1247 .collect();
1248 opts.allow_pipe_separator = true;
1249 opts.long_string_maxlines = 99;
1250 opts.documents = vec!["docs".into()];
1251 opts.plugin_processing_mode = "raw".into();
1252 opts.custom = std::iter::once(("k".to_string(), "v".to_string())).collect();
1253 opts.set_options = std::iter::once("booking_method".to_string()).collect();
1254 let restored: Options = CachedOptions::from(&opts).into();
1257 assert_eq!(
1258 restored, opts,
1259 "a CachedOptions field was dropped on the cache round-trip"
1260 );
1261 }
1262
1263 #[test]
1268 fn test_cached_options_preserves_set_options_for_booking_method() {
1269 let mut opts = Options::new();
1270 opts.set("booking_method", "FIFO");
1273 assert!(opts.set_options.contains("booking_method"));
1274
1275 let cached = CachedOptions::from(&opts);
1276 let restored: Options = cached.into();
1277
1278 assert_eq!(restored.booking_method, "FIFO");
1279 assert!(
1280 restored.set_options.contains("booking_method"),
1281 "set_options dropped across cache round-trip — booking method \
1282 resolution would fall back to the STRICT default on a cache hit"
1283 );
1284 }
1285
1286 #[test]
1287 fn test_cache_entry_file_paths() {
1288 let entry = CacheEntry {
1289 directives: vec![],
1290 options: CachedOptions::from(&Options::new()),
1291 plugins: vec![],
1292 files: vec![
1293 "/path/to/ledger.beancount".to_string(),
1294 "/path/to/include.beancount".to_string(),
1295 ],
1296 };
1297
1298 let paths = entry.file_paths();
1299 assert_eq!(paths.len(), 2);
1300 assert_eq!(paths[0], PathBuf::from("/path/to/ledger.beancount"));
1301 assert_eq!(paths[1], PathBuf::from("/path/to/include.beancount"));
1302 }
1303
1304 #[test]
1305 fn test_reintern_balance_directive() {
1306 use rustledger_core::Balance;
1307
1308 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1309 let balance = Balance::new(date, "Assets:Checking", Amount::new(dec!(1000.00), "USD"));
1310
1311 let mut directives = vec![
1312 Spanned::new(Directive::Balance(balance.clone()), Span::new(0, 50)),
1313 Spanned::new(Directive::Balance(balance), Span::new(51, 100)),
1314 ];
1315
1316 let dedup_count = reintern_directives(&mut directives);
1317 assert_eq!(dedup_count, 2);
1319 }
1320
1321 #[test]
1322 fn test_reintern_open_close_directives() {
1323 use rustledger_core::{Close, Open};
1324
1325 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1326 let open = Open::new(date, "Assets:Checking");
1327 let close = Close::new(date, "Assets:Checking");
1328
1329 let mut directives = vec![
1330 Spanned::new(Directive::Open(open), Span::new(0, 50)),
1331 Spanned::new(Directive::Close(close), Span::new(51, 100)),
1332 ];
1333
1334 let dedup_count = reintern_directives(&mut directives);
1335 assert_eq!(dedup_count, 1);
1337 }
1338}