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 = 13;
353
354#[derive(Debug, Clone)]
356struct CacheHeader {
357 magic: [u8; 8],
359 version: u32,
361 hash: [u8; 32],
363 data_len: u64,
365}
366
367impl CacheHeader {
368 const SIZE: usize = 8 + 4 + 32 + 8;
369
370 fn to_bytes(&self) -> [u8; Self::SIZE] {
371 let mut buf = [0u8; Self::SIZE];
372 buf[0..8].copy_from_slice(&self.magic);
373 buf[8..12].copy_from_slice(&self.version.to_le_bytes());
374 buf[12..44].copy_from_slice(&self.hash);
375 buf[44..52].copy_from_slice(&self.data_len.to_le_bytes());
376 buf
377 }
378
379 fn from_bytes(bytes: &[u8]) -> Option<Self> {
380 if bytes.len() < Self::SIZE {
381 return None;
382 }
383
384 let mut magic = [0u8; 8];
385 magic.copy_from_slice(&bytes[0..8]);
386
387 let version = u32::from_le_bytes(bytes[8..12].try_into().ok()?);
388
389 let mut hash = [0u8; 32];
390 hash.copy_from_slice(&bytes[12..44]);
391
392 let data_len = u64::from_le_bytes(bytes[44..52].try_into().ok()?);
393
394 Some(Self {
395 magic,
396 version,
397 hash,
398 data_len,
399 })
400 }
401}
402
403fn compute_hash(files: &[&Path]) -> [u8; 32] {
409 let mut hasher = Hasher::new();
410
411 for file in files {
412 hasher.update(file.to_string_lossy().as_bytes());
414
415 if let Ok(metadata) = fs::metadata(file) {
417 if let Ok(mtime) = metadata.modified()
418 && let Ok(duration) = mtime.duration_since(std::time::UNIX_EPOCH)
419 {
420 hasher.update(&duration.as_secs().to_le_bytes());
421 hasher.update(&duration.subsec_nanos().to_le_bytes());
422 }
423 hasher.update(&metadata.len().to_le_bytes());
425 }
426 }
427
428 *hasher.finalize().as_bytes()
429}
430
431pub const CACHE_FILENAME_ENV: &str = "BEANCOUNT_LOAD_CACHE_FILENAME";
438
439pub const DISABLE_CACHE_ENV: &str = "BEANCOUNT_DISABLE_LOAD_CACHE";
443
444pub fn cache_path(source: &Path) -> PathBuf {
462 if let Ok(pattern) = std::env::var(CACHE_FILENAME_ENV)
463 && !pattern.is_empty()
464 {
465 return resolve_cache_pattern(source, &pattern);
466 }
467 default_cache_path(source)
468}
469
470#[must_use]
476pub fn default_cache_path(source: &Path) -> PathBuf {
477 let mut path = source.to_path_buf();
478 let name = path.file_name().map_or_else(
479 || ".ledger.cache".to_string(),
480 |n| format!(".{}.cache", n.to_string_lossy()),
481 );
482 path.set_file_name(name);
483 path
484}
485
486#[allow(clippy::literal_string_with_formatting_args)]
492fn resolve_cache_pattern(source: &Path, pattern: &str) -> PathBuf {
493 let filename = source.file_name().map_or_else(
494 || "ledger".to_string(),
495 |n| n.to_string_lossy().into_owned(),
496 );
497 let resolved = pattern.replace("{filename}", &filename);
498 let p = PathBuf::from(&resolved);
499 if p.is_absolute() {
500 return p;
501 }
502 source.parent().map_or(p.clone(), |parent| parent.join(&p))
503}
504
505fn legacy_cache_path(source: &Path) -> PathBuf {
510 let mut path = source.to_path_buf();
511 let name = path.file_name().map_or_else(
512 || "ledger.cache".to_string(),
513 |n| format!("{}.cache", n.to_string_lossy()),
514 );
515 path.set_file_name(name);
516 path
517}
518
519#[must_use]
525pub fn cache_disabled_by_env() -> bool {
526 std::env::var_os(DISABLE_CACHE_ENV).is_some()
527}
528
529pub fn load_cache_entry(main_file: &Path) -> Option<CacheEntry> {
535 if cache_disabled_by_env() {
536 return None;
537 }
538 let cache_file = cache_path(main_file);
539 let mut file = fs::File::open(&cache_file).ok()?;
540
541 let mut header_bytes = [0u8; CacheHeader::SIZE];
543 file.read_exact(&mut header_bytes).ok()?;
544 let header = CacheHeader::from_bytes(&header_bytes)?;
545
546 if header.magic != *CACHE_MAGIC {
548 return None;
549 }
550 if header.version != CACHE_VERSION {
551 return None;
552 }
553
554 let mut data = vec![0u8; header.data_len as usize];
556 file.read_exact(&mut data).ok()?;
557
558 let entry: CacheEntry = rkyv::from_bytes::<CacheEntry, rkyv::rancor::Error>(&data).ok()?;
560
561 let file_paths = entry.file_paths();
563 let file_refs: Vec<&Path> = file_paths.iter().map(PathBuf::as_path).collect();
564 let expected_hash = compute_hash(&file_refs);
565 if header.hash != expected_hash {
566 return None;
567 }
568
569 Some(entry)
570}
571
572pub fn save_cache_entry(main_file: &Path, entry: &CacheEntry) -> Result<(), std::io::Error> {
576 if cache_disabled_by_env() {
577 return Ok(());
578 }
579 let cache_file = cache_path(main_file);
580
581 let file_paths = entry.file_paths();
583 let file_refs: Vec<&Path> = file_paths.iter().map(PathBuf::as_path).collect();
584 let hash = compute_hash(&file_refs);
585
586 let data = rkyv::to_bytes::<rkyv::rancor::Error>(entry)
588 .map(|v| v.to_vec())
589 .map_err(|e| std::io::Error::other(e.to_string()))?;
590
591 let header = CacheHeader {
593 magic: *CACHE_MAGIC,
594 version: CACHE_VERSION,
595 hash,
596 data_len: data.len() as u64,
597 };
598
599 if let Some(parent) = cache_file.parent()
603 && !parent.as_os_str().is_empty()
604 {
605 fs::create_dir_all(parent)?;
606 }
607
608 let mut file = fs::File::create(&cache_file)?;
609 file.write_all(&header.to_bytes())?;
610 file.write_all(&data)?;
611
612 let legacy = legacy_cache_path(main_file);
617 if legacy != cache_file && legacy.exists() {
618 let _ = fs::remove_file(&legacy);
619 }
620
621 Ok(())
622}
623
624#[cfg(test)]
626fn serialize_directives(directives: &Vec<Spanned<Directive>>) -> Result<Vec<u8>, std::io::Error> {
627 rkyv::to_bytes::<rkyv::rancor::Error>(directives)
628 .map(|v| v.to_vec())
629 .map_err(|e| std::io::Error::other(e.to_string()))
630}
631
632#[cfg(test)]
634fn deserialize_directives(data: &[u8]) -> Option<Vec<Spanned<Directive>>> {
635 rkyv::from_bytes::<Vec<Spanned<Directive>>, rkyv::rancor::Error>(data).ok()
636}
637
638pub fn invalidate_cache(main_file: &Path) {
643 let cache_file = cache_path(main_file);
644 let _ = fs::remove_file(&cache_file);
645
646 let legacy = legacy_cache_path(main_file);
647 if legacy != cache_file {
648 let _ = fs::remove_file(&legacy);
649 }
650}
651
652#[cfg(test)]
653mod tests {
654 use super::*;
655 use crate::dedup::reintern_directives;
656 use rust_decimal_macros::dec;
657 use rustledger_core::{Amount, Posting, Transaction};
658 use rustledger_parser::Span;
659
660 #[test]
661 fn test_cache_header_roundtrip() {
662 let header = CacheHeader {
663 magic: *CACHE_MAGIC,
664 version: CACHE_VERSION,
665 hash: [42u8; 32],
666 data_len: 12345,
667 };
668
669 let bytes = header.to_bytes();
670 let parsed = CacheHeader::from_bytes(&bytes).unwrap();
671
672 assert_eq!(parsed.magic, header.magic);
673 assert_eq!(parsed.version, header.version);
674 assert_eq!(parsed.hash, header.hash);
675 assert_eq!(parsed.data_len, header.data_len);
676 }
677
678 #[test]
679 fn test_compute_hash_deterministic() {
680 let files: Vec<&Path> = vec![];
681 let hash1 = compute_hash(&files);
682 let hash2 = compute_hash(&files);
683 assert_eq!(hash1, hash2);
684 }
685
686 #[test]
687 fn test_serialize_deserialize_roundtrip() {
688 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
689
690 let txn = Transaction::new(date, "Test transaction")
691 .with_payee("Test Payee")
692 .with_synthesized_posting(Posting::new(
693 "Expenses:Test",
694 Amount::new(dec!(100.00), "USD"),
695 ))
696 .with_synthesized_posting(Posting::auto("Assets:Checking"));
697
698 let directives = vec![Spanned::new(Directive::Transaction(txn), Span::new(0, 100))];
699
700 let serialized = serialize_directives(&directives).expect("serialization failed");
702
703 let deserialized = deserialize_directives(&serialized).expect("deserialization failed");
705
706 assert_eq!(directives.len(), deserialized.len());
708 let orig_txn = directives[0].value.as_transaction().unwrap();
709 let deser_txn = deserialized[0].value.as_transaction().unwrap();
710
711 assert_eq!(orig_txn.date, deser_txn.date);
712 assert_eq!(orig_txn.payee, deser_txn.payee);
713 assert_eq!(orig_txn.narration, deser_txn.narration);
714 assert_eq!(orig_txn.postings.len(), deser_txn.postings.len());
715
716 assert_eq!(orig_txn.postings[0].account, deser_txn.postings[0].account);
718 assert_eq!(orig_txn.postings[0].units, deser_txn.postings[0].units);
719 }
720
721 #[test]
722 #[ignore = "manual benchmark - run with: cargo test -p rustledger-loader --release -- --ignored --nocapture"]
723 fn bench_cache_performance() {
724 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
726 let mut directives = Vec::with_capacity(10000);
727
728 for i in 0..10000 {
729 let txn = Transaction::new(date, format!("Transaction {i}"))
730 .with_payee("Store")
731 .with_synthesized_posting(Posting::new(
732 "Expenses:Food",
733 Amount::new(dec!(25.00), "USD"),
734 ))
735 .with_synthesized_posting(Posting::auto("Assets:Checking"));
736
737 directives.push(Spanned::new(Directive::Transaction(txn), Span::new(0, 100)));
738 }
739
740 println!("\n=== Cache Benchmark (10,000 directives) ===");
741
742 let start = std::time::Instant::now();
744 let serialized = serialize_directives(&directives).unwrap();
745 let serialize_time = start.elapsed();
746 println!(
747 "Serialize: {:?} ({:.2} MB)",
748 serialize_time,
749 serialized.len() as f64 / 1_000_000.0
750 );
751
752 let start = std::time::Instant::now();
754 let deserialized = deserialize_directives(&serialized).unwrap();
755 let deserialize_time = start.elapsed();
756 println!("Deserialize: {deserialize_time:?}");
757
758 assert_eq!(directives.len(), deserialized.len());
759
760 println!(
761 "\nSpeedup potential: If parsing takes 100ms, cache load would be {:.1}x faster",
762 100.0 / deserialize_time.as_millis() as f64
763 );
764 }
765
766 fn assert_clean_cache_env() {
777 for var in [CACHE_FILENAME_ENV, DISABLE_CACHE_ENV] {
778 assert!(
779 std::env::var_os(var).is_none(),
780 "unset {var} before running this test"
781 );
782 }
783 }
784
785 #[test]
786 fn test_resolve_cache_pattern_relative_with_substitution() {
787 let source = Path::new("/home/user/finances/main.beancount");
788 let resolved = resolve_cache_pattern(source, ".cache/{filename}.bin");
789 assert_eq!(
790 resolved,
791 Path::new("/home/user/finances/.cache/main.beancount.bin")
792 );
793 }
794
795 #[test]
796 fn test_resolve_cache_pattern_absolute() {
797 let source = Path::new("/home/user/main.beancount");
798 let resolved = resolve_cache_pattern(source, "/var/cache/rledger/{filename}.cache");
799 assert_eq!(
800 resolved,
801 Path::new("/var/cache/rledger/main.beancount.cache")
802 );
803 }
804
805 #[test]
806 fn test_resolve_cache_pattern_no_substitution() {
807 let source = Path::new("/home/user/main.beancount");
809 let resolved = resolve_cache_pattern(source, "fixed.cache");
810 assert_eq!(resolved, Path::new("/home/user/fixed.cache"));
811 }
812
813 #[test]
814 fn test_legacy_cache_path() {
815 let source = Path::new("/tmp/ledger.beancount");
816 assert_eq!(
817 legacy_cache_path(source),
818 Path::new("/tmp/ledger.beancount.cache")
819 );
820 }
821
822 #[test]
823 fn test_save_load_cache_entry_roundtrip() {
824 use std::io::Write;
825
826 assert_clean_cache_env();
827
828 let temp_dir = std::env::temp_dir().join("rustledger_cache_test");
830 let _ = fs::create_dir_all(&temp_dir);
831
832 let beancount_file = temp_dir.join("test.beancount");
834 let mut f = fs::File::create(&beancount_file).unwrap();
835 writeln!(f, "2024-01-01 open Assets:Test").unwrap();
836 drop(f);
837
838 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
840 let txn =
841 Transaction::new(date, "Test").with_synthesized_posting(Posting::auto("Assets:Test"));
842 let directives = vec![Spanned::new(Directive::Transaction(txn), Span::new(0, 50))];
843
844 let entry = CacheEntry {
845 directives,
846 options: CachedOptions::from(&Options::new()),
847 plugins: vec![CachedPlugin {
848 name: "test_plugin".to_string(),
849 config: Some("config".to_string()),
850 force_python: false,
851 }],
852 files: vec![beancount_file.to_string_lossy().to_string()],
853 };
854
855 save_cache_entry(&beancount_file, &entry).expect("save failed");
857
858 let loaded = load_cache_entry(&beancount_file).expect("load failed");
860
861 assert_eq!(loaded.directives.len(), entry.directives.len());
863 assert_eq!(loaded.plugins.len(), 1);
864 assert_eq!(loaded.plugins[0].name, "test_plugin");
865 assert_eq!(loaded.plugins[0].config, Some("config".to_string()));
866 assert_eq!(loaded.files.len(), 1);
867
868 let _ = fs::remove_file(&beancount_file);
870 let _ = fs::remove_file(cache_path(&beancount_file));
871 let _ = fs::remove_dir(&temp_dir);
872 }
873
874 #[test]
875 fn test_invalidate_cache() {
876 use std::io::Write;
877
878 assert_clean_cache_env();
879
880 let temp_dir = std::env::temp_dir().join("rustledger_invalidate_test");
881 let _ = fs::create_dir_all(&temp_dir);
882
883 let beancount_file = temp_dir.join("test.beancount");
884 let mut f = fs::File::create(&beancount_file).unwrap();
885 writeln!(f, "2024-01-01 open Assets:Test").unwrap();
886 drop(f);
887
888 let entry = CacheEntry {
890 directives: vec![],
891 options: CachedOptions::from(&Options::new()),
892 plugins: vec![],
893 files: vec![beancount_file.to_string_lossy().to_string()],
894 };
895 save_cache_entry(&beancount_file, &entry).unwrap();
896
897 assert!(cache_path(&beancount_file).exists());
899
900 invalidate_cache(&beancount_file);
902
903 assert!(!cache_path(&beancount_file).exists());
905
906 let _ = fs::remove_file(&beancount_file);
908 let _ = fs::remove_dir(&temp_dir);
909 }
910
911 #[test]
912 fn test_invalidate_cache_removes_legacy_sidecar() {
913 assert_clean_cache_env();
916
917 let temp_dir = std::env::temp_dir().join("rustledger_invalidate_legacy_test");
918 let _ = fs::create_dir_all(&temp_dir);
919
920 let beancount_file = temp_dir.join("legacy.beancount");
921 let legacy = legacy_cache_path(&beancount_file);
924 fs::write(&legacy, b"stale").unwrap();
925 assert!(legacy.exists());
926
927 invalidate_cache(&beancount_file);
928 assert!(
929 !legacy.exists(),
930 "invalidate_cache should remove the legacy sidecar file"
931 );
932
933 let _ = fs::remove_dir(&temp_dir);
934 }
935
936 #[test]
937 fn test_load_cache_missing_file() {
938 let missing = Path::new("/nonexistent/path/to/file.beancount");
939 assert!(load_cache_entry(missing).is_none());
940 }
941
942 #[test]
943 fn test_load_cache_invalid_magic() {
944 use std::io::Write;
945
946 assert_clean_cache_env();
947
948 let temp_dir = std::env::temp_dir().join("rustledger_magic_test");
949 let _ = fs::create_dir_all(&temp_dir);
950
951 let beancount_file = temp_dir.join("test.beancount");
952 let cache_file = cache_path(&beancount_file);
954 let mut f = fs::File::create(&cache_file).unwrap();
955 f.write_all(b"INVALID\0").unwrap();
957 f.write_all(&[0u8; CacheHeader::SIZE - 8]).unwrap();
958 drop(f);
959
960 assert!(load_cache_entry(&beancount_file).is_none());
961
962 let _ = fs::remove_file(&cache_file);
964 let _ = fs::remove_dir(&temp_dir);
965 }
966
967 #[test]
973 fn test_load_cache_rejects_older_version() {
974 use std::io::Write;
975
976 assert_clean_cache_env();
977
978 let temp_dir = std::env::temp_dir().join("rustledger_old_version_test");
979 let _ = fs::create_dir_all(&temp_dir);
980
981 let beancount_file = temp_dir.join("test.beancount");
982 let cache_file = cache_path(&beancount_file);
983 let mut f = fs::File::create(&cache_file).unwrap();
984
985 let stale_version: u32 = CACHE_VERSION.checked_sub(1).expect("CACHE_VERSION >= 1");
989 f.write_all(CACHE_MAGIC).unwrap();
990 f.write_all(&stale_version.to_le_bytes()).unwrap();
991 f.write_all(&[0u8; CacheHeader::SIZE - 8 - 4]).unwrap();
992 drop(f);
993
994 assert!(
995 load_cache_entry(&beancount_file).is_none(),
996 "loader must reject cache files with an older CACHE_VERSION"
997 );
998
999 let _ = fs::remove_file(&cache_file);
1000 let _ = fs::remove_dir(&temp_dir);
1001 }
1002
1003 #[cfg(target_endian = "little")]
1036 #[test]
1037 fn cost_number_archived_bytes_match_v8_fixtures() {
1038 use rust_decimal_macros::dec;
1039 use rustledger_core::{BookedCost, CostNumber};
1040
1041 const FIXTURE_VERSION: u32 = 13;
1056 assert_eq!(
1057 CACHE_VERSION, FIXTURE_VERSION,
1058 "CACHE_VERSION advanced past the fixture version; regenerate \
1059 the byte fixtures in this test and update FIXTURE_VERSION, \
1060 or remove the tripwire if v{CACHE_VERSION}'s CostNumber \
1061 encoding is byte-identical to the fixtures.",
1062 );
1063
1064 let cases: &[(&str, CostNumber, &[u8])] = &[
1065 (
1066 "PerUnit { value: 150 }",
1067 CostNumber::PerUnit { value: dec!(150) },
1068 &[
1069 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,
1070 0, 0, 0, 0, 0, 0, 0,
1071 ],
1072 ),
1073 (
1074 "Compound { per_unit: 5, total: 10 }",
1075 CostNumber::Compound {
1076 per_unit: dec!(5),
1077 total: dec!(10),
1078 },
1079 &[
1080 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,
1081 0, 0, 0, 0, 0, 0, 0,
1082 ],
1083 ),
1084 (
1085 "Total { value: 1500 }",
1086 CostNumber::Total { value: dec!(1500) },
1087 &[
1088 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,
1089 0, 0, 0, 0, 0, 0, 0,
1090 ],
1091 ),
1092 (
1093 "PerUnitFromTotal { per_unit: 150, total: 300 }",
1094 CostNumber::PerUnitFromTotal(BookedCost::new(dec!(150), dec!(300), dec!(2))),
1095 &[
1096 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,
1097 0, 0, 0, 0, 0, 0, 0, 0,
1098 ],
1099 ),
1100 ];
1101 let mut mismatches = Vec::new();
1102 for (name, cn, expected) in cases {
1103 let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(cn).unwrap();
1104 if bytes.as_ref() != *expected {
1105 mismatches.push(format!(" `{name}` → {:?}", bytes.as_ref()));
1106 }
1107 }
1108 assert!(
1109 mismatches.is_empty(),
1110 "rkyv layout drifted from v8 fixtures — bump CACHE_VERSION and \
1111 update the fixtures in this test if intentional. Actual bytes:\n{}",
1112 mismatches.join("\n"),
1113 );
1114 }
1115
1116 #[cfg(target_endian = "little")]
1133 #[test]
1134 fn meta_value_archived_layout_hash_matches() {
1135 use rustledger_core::{Account, Currency, Link, MetaValue, Tag};
1136
1137 const FIXTURE_VERSION: u32 = 13;
1143 const META_VALUE_LAYOUT_HASH: &str =
1144 "43e3c258fe376cede6a6c2c975100bcf67ddda0ab84b21566b123c01e0a54b25";
1145 assert_eq!(
1146 CACHE_VERSION, FIXTURE_VERSION,
1147 "CACHE_VERSION advanced past the MetaValue layout-hash fixture; if the \
1148 MetaValue archived layout changed, bump CACHE_VERSION and regenerate \
1149 META_VALUE_LAYOUT_HASH below in the same commit, else just bump \
1150 FIXTURE_VERSION.",
1151 );
1152
1153 let variants: &[MetaValue] = &[
1156 MetaValue::String("USD".to_string()),
1157 MetaValue::Account(Account::from("Assets:Bank")),
1158 MetaValue::Currency(Currency::from("USD")),
1159 MetaValue::Tag(Tag::from("t")),
1160 MetaValue::Link(Link::from("t")),
1161 MetaValue::Date(rustledger_core::naive_date(2024, 1, 15).unwrap()),
1162 MetaValue::Number(dec!(42)),
1163 MetaValue::Bool(true),
1164 MetaValue::Amount(Amount::new(dec!(10), "USD")),
1165 MetaValue::None,
1166 MetaValue::Int(42),
1167 ];
1168
1169 let mut hasher = Hasher::new();
1170 for mv in variants {
1171 let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(mv).unwrap();
1172 hasher.update(&(bytes.len() as u64).to_le_bytes());
1175 hasher.update(&bytes);
1176 }
1177 let digest = hasher.finalize().to_hex();
1178
1179 assert_eq!(
1180 digest.as_str(),
1181 META_VALUE_LAYOUT_HASH,
1182 "MetaValue archived layout changed. If intentional, bump CACHE_VERSION \
1183 and set META_VALUE_LAYOUT_HASH to: {digest}",
1184 );
1185 }
1186
1187 #[test]
1188 fn test_reintern_directives_deduplication() {
1189 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1190
1191 let mut directives = vec![];
1193 for i in 0..5 {
1194 let txn = Transaction::new(date, format!("Txn {i}"))
1195 .with_synthesized_posting(Posting::new(
1196 "Expenses:Food",
1197 Amount::new(dec!(10.00), "USD"),
1198 ))
1199 .with_synthesized_posting(Posting::auto("Assets:Checking"));
1200 directives.push(Spanned::new(Directive::Transaction(txn), Span::new(0, 50)));
1201 }
1202
1203 let dedup_count = reintern_directives(&mut directives);
1205
1206 assert_eq!(dedup_count, 12);
1212 }
1213
1214 #[test]
1215 fn test_cached_options_roundtrip() {
1216 let mut opts = Options::new();
1217 opts.title = Some("Test Ledger".to_string());
1218 opts.operating_currency = vec!["USD".to_string(), "EUR".to_string()];
1219 opts.render_commas = true;
1220
1221 let cached = CachedOptions::from(&opts);
1222 let restored: Options = cached.into();
1223
1224 assert_eq!(restored.title, Some("Test Ledger".to_string()));
1225 assert_eq!(restored.operating_currency, vec!["USD", "EUR"]);
1226 assert!(restored.render_commas);
1227 }
1228
1229 #[test]
1240 fn cached_options_field_parity() {
1241 use rust_decimal_macros::dec;
1242
1243 let mut opts = Options::new();
1244 opts.title = Some("T".into());
1245 opts.filename = Some("f.beancount".into());
1246 opts.operating_currency = vec!["USD".into(), "EUR".into()];
1247 opts.name_assets = "A".into();
1248 opts.name_liabilities = "L".into();
1249 opts.name_equity = "Q".into();
1250 opts.name_income = "I".into();
1251 opts.name_expenses = "X".into();
1252 opts.account_rounding = Some("Equity:Round".into());
1253 opts.account_previous_balances = "Opening".into();
1254 opts.account_previous_earnings = "Earn".into();
1255 opts.account_previous_conversions = "Conv".into();
1256 opts.account_current_earnings = "CurEarn".into();
1257 opts.account_current_conversions = Some("CurConv".into());
1258 opts.account_unrealized_gains = Some("Unreal".into());
1259 opts.conversion_currency = Some("NOTHING".into());
1260 opts.inferred_tolerance_default =
1261 std::iter::once(("USD".to_string(), dec!(0.005))).collect();
1262 opts.inferred_tolerance_multiplier = dec!(1.5);
1263 opts.infer_tolerance_from_cost = true;
1264 opts.use_legacy_fixed_tolerances = true;
1265 opts.experiment_explicit_tolerances = true;
1266 opts.use_precise_interpolation = true;
1267 opts.booking_method = "FIFO".into();
1268 opts.render_commas = true;
1269 opts.display_precision = [("USD".to_string(), 4u32), ("JPY".to_string(), 0)]
1270 .into_iter()
1271 .collect();
1272 opts.allow_pipe_separator = true;
1273 opts.long_string_maxlines = 99;
1274 opts.documents = vec!["docs".into()];
1275 opts.plugin_processing_mode = "raw".into();
1276 opts.custom = std::iter::once(("k".to_string(), "v".to_string())).collect();
1277 opts.set_options = std::iter::once("booking_method".to_string()).collect();
1278 let restored: Options = CachedOptions::from(&opts).into();
1281 assert_eq!(
1282 restored, opts,
1283 "a CachedOptions field was dropped on the cache round-trip"
1284 );
1285 }
1286
1287 #[test]
1292 fn test_cached_options_preserves_set_options_for_booking_method() {
1293 let mut opts = Options::new();
1294 opts.set("booking_method", "FIFO");
1297 assert!(opts.set_options.contains("booking_method"));
1298
1299 let cached = CachedOptions::from(&opts);
1300 let restored: Options = cached.into();
1301
1302 assert_eq!(restored.booking_method, "FIFO");
1303 assert!(
1304 restored.set_options.contains("booking_method"),
1305 "set_options dropped across cache round-trip — booking method \
1306 resolution would fall back to the STRICT default on a cache hit"
1307 );
1308 }
1309
1310 #[test]
1311 fn test_cache_entry_file_paths() {
1312 let entry = CacheEntry {
1313 directives: vec![],
1314 options: CachedOptions::from(&Options::new()),
1315 plugins: vec![],
1316 files: vec![
1317 "/path/to/ledger.beancount".to_string(),
1318 "/path/to/include.beancount".to_string(),
1319 ],
1320 };
1321
1322 let paths = entry.file_paths();
1323 assert_eq!(paths.len(), 2);
1324 assert_eq!(paths[0], PathBuf::from("/path/to/ledger.beancount"));
1325 assert_eq!(paths[1], PathBuf::from("/path/to/include.beancount"));
1326 }
1327
1328 #[test]
1329 fn test_reintern_balance_directive() {
1330 use rustledger_core::Balance;
1331
1332 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1333 let balance = Balance::new(date, "Assets:Checking", Amount::new(dec!(1000.00), "USD"));
1334
1335 let mut directives = vec![
1336 Spanned::new(Directive::Balance(balance.clone()), Span::new(0, 50)),
1337 Spanned::new(Directive::Balance(balance), Span::new(51, 100)),
1338 ];
1339
1340 let dedup_count = reintern_directives(&mut directives);
1341 assert_eq!(dedup_count, 2);
1343 }
1344
1345 #[test]
1346 fn test_reintern_open_close_directives() {
1347 use rustledger_core::{Close, Open};
1348
1349 let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1350 let open = Open::new(date, "Assets:Checking");
1351 let close = Close::new(date, "Assets:Checking");
1352
1353 let mut directives = vec![
1354 Spanned::new(Directive::Open(open), Span::new(0, 50)),
1355 Spanned::new(Directive::Close(close), Span::new(51, 100)),
1356 ];
1357
1358 let dedup_count = reintern_directives(&mut directives);
1359 assert_eq!(dedup_count, 1);
1361 }
1362}