Skip to main content

rustledger_loader/
cache.rs

1//! Binary cache for parsed ledgers.
2//!
3//! This module provides a caching layer that can dramatically speed up
4//! subsequent loads of unchanged beancount files by serializing the parsed
5//! directives to a binary format using rkyv.
6//!
7//! # How it works
8//!
9//! 1. When loading a file, compute a hash of all source files
10//! 2. Check if a cache file exists with a matching hash
11//! 3. If yes, deserialize and return immediately (typically <1ms)
12//! 4. If no, parse normally, serialize to cache, and return
13//!
14//! # Cache location
15//!
16//! Cache files are stored alongside the main ledger file with a `.cache` extension.
17//! For example, `ledger.beancount` would have cache at `ledger.beancount.cache`.
18
19use crate::Options;
20use rust_decimal::Decimal;
21use rustledger_core::Directive;
22use rustledger_core::intern::StringInterner;
23use rustledger_parser::Spanned;
24use sha2::{Digest, Sha256};
25use std::fs;
26use std::io::{Read, Write};
27use std::path::{Path, PathBuf};
28use std::str::FromStr;
29
30/// Cached plugin information.
31#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
32pub struct CachedPlugin {
33    /// Plugin module name.
34    pub name: String,
35    /// Optional configuration string.
36    pub config: Option<String>,
37    /// Whether the `python:` prefix was used to force Python execution.
38    pub force_python: bool,
39}
40
41/// Cached options - a serializable subset of Options.
42///
43/// Excludes parsing-time fields like `set_options` and `warnings`.
44/// These fields mirror the Options struct and inherit their meaning.
45#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
46#[allow(missing_docs)]
47pub struct CachedOptions {
48    pub title: Option<String>,
49    pub filename: Option<String>,
50    pub operating_currency: Vec<String>,
51    pub name_assets: String,
52    pub name_liabilities: String,
53    pub name_equity: String,
54    pub name_income: String,
55    pub name_expenses: String,
56    pub account_rounding: Option<String>,
57    pub account_previous_balances: String,
58    pub account_previous_earnings: String,
59    pub account_previous_conversions: String,
60    pub account_current_earnings: String,
61    pub account_current_conversions: Option<String>,
62    pub account_unrealized_gains: Option<String>,
63    pub conversion_currency: Option<String>,
64    /// Stored as (currency, `tolerance_string`) pairs since Decimal needs special handling
65    pub inferred_tolerance_default: Vec<(String, String)>,
66    pub inferred_tolerance_multiplier: String,
67    pub infer_tolerance_from_cost: bool,
68    pub use_legacy_fixed_tolerances: bool,
69    pub experiment_explicit_tolerances: bool,
70    pub booking_method: String,
71    pub render_commas: bool,
72    pub allow_pipe_separator: bool,
73    pub long_string_maxlines: u32,
74    pub documents: Vec<String>,
75    pub custom: Vec<(String, String)>,
76}
77
78impl From<&Options> for CachedOptions {
79    fn from(opts: &Options) -> Self {
80        Self {
81            title: opts.title.clone(),
82            filename: opts.filename.clone(),
83            operating_currency: opts.operating_currency.clone(),
84            name_assets: opts.name_assets.clone(),
85            name_liabilities: opts.name_liabilities.clone(),
86            name_equity: opts.name_equity.clone(),
87            name_income: opts.name_income.clone(),
88            name_expenses: opts.name_expenses.clone(),
89            account_rounding: opts.account_rounding.clone(),
90            account_previous_balances: opts.account_previous_balances.clone(),
91            account_previous_earnings: opts.account_previous_earnings.clone(),
92            account_previous_conversions: opts.account_previous_conversions.clone(),
93            account_current_earnings: opts.account_current_earnings.clone(),
94            account_current_conversions: opts.account_current_conversions.clone(),
95            account_unrealized_gains: opts.account_unrealized_gains.clone(),
96            conversion_currency: opts.conversion_currency.clone(),
97            inferred_tolerance_default: opts
98                .inferred_tolerance_default
99                .iter()
100                .map(|(k, v)| (k.clone(), v.to_string()))
101                .collect(),
102            inferred_tolerance_multiplier: opts.inferred_tolerance_multiplier.to_string(),
103            infer_tolerance_from_cost: opts.infer_tolerance_from_cost,
104            use_legacy_fixed_tolerances: opts.use_legacy_fixed_tolerances,
105            experiment_explicit_tolerances: opts.experiment_explicit_tolerances,
106            booking_method: opts.booking_method.clone(),
107            render_commas: opts.render_commas,
108            allow_pipe_separator: opts.allow_pipe_separator,
109            long_string_maxlines: opts.long_string_maxlines,
110            documents: opts.documents.clone(),
111            custom: opts
112                .custom
113                .iter()
114                .map(|(k, v)| (k.clone(), v.clone()))
115                .collect(),
116        }
117    }
118}
119
120impl From<CachedOptions> for Options {
121    fn from(cached: CachedOptions) -> Self {
122        let mut opts = Self::new();
123        opts.title = cached.title;
124        opts.filename = cached.filename;
125        opts.operating_currency = cached.operating_currency;
126        opts.name_assets = cached.name_assets;
127        opts.name_liabilities = cached.name_liabilities;
128        opts.name_equity = cached.name_equity;
129        opts.name_income = cached.name_income;
130        opts.name_expenses = cached.name_expenses;
131        opts.account_rounding = cached.account_rounding;
132        opts.account_previous_balances = cached.account_previous_balances;
133        opts.account_previous_earnings = cached.account_previous_earnings;
134        opts.account_previous_conversions = cached.account_previous_conversions;
135        opts.account_current_earnings = cached.account_current_earnings;
136        opts.account_current_conversions = cached.account_current_conversions;
137        opts.account_unrealized_gains = cached.account_unrealized_gains;
138        opts.conversion_currency = cached.conversion_currency;
139        opts.inferred_tolerance_default = cached
140            .inferred_tolerance_default
141            .into_iter()
142            .filter_map(|(k, v)| Decimal::from_str(&v).ok().map(|d| (k, d)))
143            .collect();
144        opts.inferred_tolerance_multiplier =
145            Decimal::from_str(&cached.inferred_tolerance_multiplier)
146                .unwrap_or_else(|_| Decimal::new(5, 1));
147        opts.infer_tolerance_from_cost = cached.infer_tolerance_from_cost;
148        opts.use_legacy_fixed_tolerances = cached.use_legacy_fixed_tolerances;
149        opts.experiment_explicit_tolerances = cached.experiment_explicit_tolerances;
150        opts.booking_method = cached.booking_method;
151        opts.render_commas = cached.render_commas;
152        opts.allow_pipe_separator = cached.allow_pipe_separator;
153        opts.long_string_maxlines = cached.long_string_maxlines;
154        opts.documents = cached.documents;
155        opts.custom = cached.custom.into_iter().collect();
156        opts
157    }
158}
159
160/// Complete cache entry containing all data needed to restore a `LoadResult`.
161#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
162pub struct CacheEntry {
163    /// All parsed directives.
164    pub directives: Vec<Spanned<Directive>>,
165    /// Parsed options.
166    pub options: CachedOptions,
167    /// Plugin declarations.
168    pub plugins: Vec<CachedPlugin>,
169    /// All files that were loaded (as strings, for serialization).
170    pub files: Vec<String>,
171}
172
173impl CacheEntry {
174    /// Get files as `PathBuf` references.
175    pub fn file_paths(&self) -> Vec<PathBuf> {
176        self.files.iter().map(PathBuf::from).collect()
177    }
178}
179
180/// Magic bytes to identify cache files.
181const CACHE_MAGIC: &[u8; 8] = b"RLEDGER\0";
182
183/// Cache version - increment when format changes.
184/// v1: Initial release with string-based Decimal/NaiveDate
185/// v2: Binary Decimal (16 bytes) and `NaiveDate` (i32 days)
186/// v3: Fixed account type defaults in `CachedOptions`
187const CACHE_VERSION: u32 = 3;
188
189/// Cache header stored at the start of cache files.
190#[derive(Debug, Clone)]
191struct CacheHeader {
192    /// Magic bytes for identification.
193    magic: [u8; 8],
194    /// Cache format version.
195    version: u32,
196    /// SHA-256 hash of source files.
197    hash: [u8; 32],
198    /// Length of the serialized data.
199    data_len: u64,
200}
201
202impl CacheHeader {
203    const SIZE: usize = 8 + 4 + 32 + 8;
204
205    fn to_bytes(&self) -> [u8; Self::SIZE] {
206        let mut buf = [0u8; Self::SIZE];
207        buf[0..8].copy_from_slice(&self.magic);
208        buf[8..12].copy_from_slice(&self.version.to_le_bytes());
209        buf[12..44].copy_from_slice(&self.hash);
210        buf[44..52].copy_from_slice(&self.data_len.to_le_bytes());
211        buf
212    }
213
214    fn from_bytes(bytes: &[u8]) -> Option<Self> {
215        if bytes.len() < Self::SIZE {
216            return None;
217        }
218
219        let mut magic = [0u8; 8];
220        magic.copy_from_slice(&bytes[0..8]);
221
222        let version = u32::from_le_bytes(bytes[8..12].try_into().ok()?);
223
224        let mut hash = [0u8; 32];
225        hash.copy_from_slice(&bytes[12..44]);
226
227        let data_len = u64::from_le_bytes(bytes[44..52].try_into().ok()?);
228
229        Some(Self {
230            magic,
231            version,
232            hash,
233            data_len,
234        })
235    }
236}
237
238/// Compute a hash of the given files and their modification times.
239///
240/// Files whose metadata cannot be read (e.g., deleted between load and cache)
241/// contribute only their path to the hash. This is intentional — the resulting
242/// hash mismatch will cause a cache miss on next load.
243fn compute_hash(files: &[&Path]) -> [u8; 32] {
244    let mut hasher = Sha256::new();
245
246    for file in files {
247        // Hash the file path
248        hasher.update(file.to_string_lossy().as_bytes());
249
250        // Hash the modification time (skip silently if inaccessible)
251        if let Ok(metadata) = fs::metadata(file) {
252            if let Ok(mtime) = metadata.modified()
253                && let Ok(duration) = mtime.duration_since(std::time::UNIX_EPOCH)
254            {
255                hasher.update(duration.as_secs().to_le_bytes());
256                hasher.update(duration.subsec_nanos().to_le_bytes());
257            }
258            // Hash the file size
259            hasher.update(metadata.len().to_le_bytes());
260        }
261    }
262
263    hasher.finalize().into()
264}
265
266/// Get the cache file path for a given source file.
267fn cache_path(source: &Path) -> std::path::PathBuf {
268    let mut path = source.to_path_buf();
269    let name = path.file_name().map_or_else(
270        || "ledger.cache".to_string(),
271        |n| format!("{}.cache", n.to_string_lossy()),
272    );
273    path.set_file_name(name);
274    path
275}
276
277/// Try to load a cache entry from disk.
278///
279/// Returns `Some(CacheEntry)` if cache is valid and file hashes match,
280/// `None` if cache is missing, invalid, or outdated.
281pub fn load_cache_entry(main_file: &Path) -> Option<CacheEntry> {
282    let cache_file = cache_path(main_file);
283    let mut file = fs::File::open(&cache_file).ok()?;
284
285    // Read header
286    let mut header_bytes = [0u8; CacheHeader::SIZE];
287    file.read_exact(&mut header_bytes).ok()?;
288    let header = CacheHeader::from_bytes(&header_bytes)?;
289
290    // Validate magic and version
291    if header.magic != *CACHE_MAGIC {
292        return None;
293    }
294    if header.version != CACHE_VERSION {
295        return None;
296    }
297
298    // Read data
299    let mut data = vec![0u8; header.data_len as usize];
300    file.read_exact(&mut data).ok()?;
301
302    // Deserialize
303    let entry: CacheEntry = rkyv::from_bytes::<CacheEntry, rkyv::rancor::Error>(&data).ok()?;
304
305    // Validate hash against the files stored in the cache
306    let file_paths = entry.file_paths();
307    let file_refs: Vec<&Path> = file_paths.iter().map(PathBuf::as_path).collect();
308    let expected_hash = compute_hash(&file_refs);
309    if header.hash != expected_hash {
310        return None;
311    }
312
313    Some(entry)
314}
315
316/// Save a cache entry to disk.
317pub fn save_cache_entry(main_file: &Path, entry: &CacheEntry) -> Result<(), std::io::Error> {
318    let cache_file = cache_path(main_file);
319
320    // Compute hash from the files in the entry
321    let file_paths = entry.file_paths();
322    let file_refs: Vec<&Path> = file_paths.iter().map(PathBuf::as_path).collect();
323    let hash = compute_hash(&file_refs);
324
325    // Serialize
326    let data = rkyv::to_bytes::<rkyv::rancor::Error>(entry)
327        .map(|v| v.to_vec())
328        .map_err(|e| std::io::Error::other(e.to_string()))?;
329
330    // Write header + data
331    let header = CacheHeader {
332        magic: *CACHE_MAGIC,
333        version: CACHE_VERSION,
334        hash,
335        data_len: data.len() as u64,
336    };
337
338    let mut file = fs::File::create(&cache_file)?;
339    file.write_all(&header.to_bytes())?;
340    file.write_all(&data)?;
341
342    Ok(())
343}
344
345/// Serialize directives to bytes using rkyv (for benchmarking).
346#[cfg(test)]
347fn serialize_directives(directives: &Vec<Spanned<Directive>>) -> Result<Vec<u8>, std::io::Error> {
348    rkyv::to_bytes::<rkyv::rancor::Error>(directives)
349        .map(|v| v.to_vec())
350        .map_err(|e| std::io::Error::other(e.to_string()))
351}
352
353/// Deserialize directives from bytes using rkyv (for benchmarking).
354#[cfg(test)]
355fn deserialize_directives(data: &[u8]) -> Option<Vec<Spanned<Directive>>> {
356    rkyv::from_bytes::<Vec<Spanned<Directive>>, rkyv::rancor::Error>(data).ok()
357}
358
359/// Invalidate the cache for a file.
360pub fn invalidate_cache(main_file: &Path) {
361    let cache_file = cache_path(main_file);
362    let _ = fs::remove_file(cache_file);
363}
364
365/// Re-intern all strings in directives to deduplicate memory.
366///
367/// After deserializing from cache, strings are not interned (each is a separate
368/// allocation). This function walks through all directives and re-interns account
369/// names and currencies using a shared `StringInterner`, deduplicating identical
370/// strings to save memory.
371///
372/// Returns the number of strings that were deduplicated (i.e., strings that
373/// were found to already exist in the interner).
374pub fn reintern_directives(directives: &mut [Spanned<Directive>]) -> usize {
375    let mut interner = StringInterner::with_capacity(1024);
376    let mut dedup_count = 0;
377    for spanned in directives.iter_mut() {
378        dedup_count += reintern_directive(&mut spanned.value, &mut interner);
379    }
380    dedup_count
381}
382
383/// Re-intern strings in a slice of plain directives (without `Spanned` wrapper).
384///
385/// This is useful for WASM caching where `Spanned<Directive>` is not used.
386pub fn reintern_plain_directives(directives: &mut [Directive]) -> usize {
387    let mut interner = StringInterner::with_capacity(1024);
388    let mut dedup_count = 0;
389    for directive in directives.iter_mut() {
390        dedup_count += reintern_directive(directive, &mut interner);
391    }
392    dedup_count
393}
394
395/// Re-intern all `InternedStr` fields in a single directive, deduplicating
396/// identical strings to share a single `Arc<str>` allocation.
397fn reintern_directive(directive: &mut Directive, interner: &mut StringInterner) -> usize {
398    use rustledger_core::intern::InternedStr;
399    use rustledger_core::{IncompleteAmount, PriceAnnotation};
400
401    fn do_intern(s: &mut InternedStr, interner: &mut StringInterner) -> bool {
402        let already_exists = interner.contains(s.as_str());
403        *s = interner.intern(s.as_str());
404        already_exists
405    }
406
407    let mut dedup_count = 0;
408
409    match directive {
410        Directive::Transaction(txn) => {
411            for posting in &mut txn.postings {
412                if do_intern(&mut posting.account, interner) {
413                    dedup_count += 1;
414                }
415                // Units
416                if let Some(ref mut units) = posting.units {
417                    match units {
418                        IncompleteAmount::Complete(amt) => {
419                            if do_intern(&mut amt.currency, interner) {
420                                dedup_count += 1;
421                            }
422                        }
423                        IncompleteAmount::CurrencyOnly(cur) => {
424                            if do_intern(cur, interner) {
425                                dedup_count += 1;
426                            }
427                        }
428                        IncompleteAmount::NumberOnly(_) => {}
429                    }
430                }
431                // Cost spec
432                if let Some(ref mut cost) = posting.cost
433                    && let Some(ref mut cur) = cost.currency
434                    && do_intern(cur, interner)
435                {
436                    dedup_count += 1;
437                }
438                // Price annotation
439                if let Some(ref mut price) = posting.price {
440                    match price {
441                        PriceAnnotation::Unit(amt) | PriceAnnotation::Total(amt) => {
442                            if do_intern(&mut amt.currency, interner) {
443                                dedup_count += 1;
444                            }
445                        }
446                        PriceAnnotation::UnitIncomplete(inc)
447                        | PriceAnnotation::TotalIncomplete(inc) => match inc {
448                            IncompleteAmount::Complete(amt) => {
449                                if do_intern(&mut amt.currency, interner) {
450                                    dedup_count += 1;
451                                }
452                            }
453                            IncompleteAmount::CurrencyOnly(cur) => {
454                                if do_intern(cur, interner) {
455                                    dedup_count += 1;
456                                }
457                            }
458                            IncompleteAmount::NumberOnly(_) => {}
459                        },
460                        PriceAnnotation::UnitEmpty | PriceAnnotation::TotalEmpty => {}
461                    }
462                }
463            }
464        }
465        Directive::Balance(bal) => {
466            if do_intern(&mut bal.account, interner) {
467                dedup_count += 1;
468            }
469            if do_intern(&mut bal.amount.currency, interner) {
470                dedup_count += 1;
471            }
472        }
473        Directive::Open(open) => {
474            if do_intern(&mut open.account, interner) {
475                dedup_count += 1;
476            }
477            for cur in &mut open.currencies {
478                if do_intern(cur, interner) {
479                    dedup_count += 1;
480                }
481            }
482        }
483        Directive::Close(close) => {
484            if do_intern(&mut close.account, interner) {
485                dedup_count += 1;
486            }
487        }
488        Directive::Commodity(comm) => {
489            if do_intern(&mut comm.currency, interner) {
490                dedup_count += 1;
491            }
492        }
493        Directive::Pad(pad) => {
494            if do_intern(&mut pad.account, interner) {
495                dedup_count += 1;
496            }
497            if do_intern(&mut pad.source_account, interner) {
498                dedup_count += 1;
499            }
500        }
501        Directive::Note(note) => {
502            if do_intern(&mut note.account, interner) {
503                dedup_count += 1;
504            }
505        }
506        Directive::Document(doc) => {
507            if do_intern(&mut doc.account, interner) {
508                dedup_count += 1;
509            }
510        }
511        Directive::Price(price) => {
512            if do_intern(&mut price.currency, interner) {
513                dedup_count += 1;
514            }
515            if do_intern(&mut price.amount.currency, interner) {
516                dedup_count += 1;
517            }
518        }
519        Directive::Event(_) | Directive::Query(_) | Directive::Custom(_) => {
520            // These don't contain InternedStr fields
521        }
522    }
523
524    dedup_count
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530    use chrono::NaiveDate;
531    use rust_decimal_macros::dec;
532    use rustledger_core::{Amount, Posting, Transaction};
533    use rustledger_parser::Span;
534
535    #[test]
536    fn test_cache_header_roundtrip() {
537        let header = CacheHeader {
538            magic: *CACHE_MAGIC,
539            version: CACHE_VERSION,
540            hash: [42u8; 32],
541            data_len: 12345,
542        };
543
544        let bytes = header.to_bytes();
545        let parsed = CacheHeader::from_bytes(&bytes).unwrap();
546
547        assert_eq!(parsed.magic, header.magic);
548        assert_eq!(parsed.version, header.version);
549        assert_eq!(parsed.hash, header.hash);
550        assert_eq!(parsed.data_len, header.data_len);
551    }
552
553    #[test]
554    fn test_compute_hash_deterministic() {
555        let files: Vec<&Path> = vec![];
556        let hash1 = compute_hash(&files);
557        let hash2 = compute_hash(&files);
558        assert_eq!(hash1, hash2);
559    }
560
561    #[test]
562    fn test_serialize_deserialize_roundtrip() {
563        let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
564
565        let txn = Transaction::new(date, "Test transaction")
566            .with_payee("Test Payee")
567            .with_posting(Posting::new(
568                "Expenses:Test",
569                Amount::new(dec!(100.00), "USD"),
570            ))
571            .with_posting(Posting::auto("Assets:Checking"));
572
573        let directives = vec![Spanned::new(Directive::Transaction(txn), Span::new(0, 100))];
574
575        // Serialize
576        let serialized = serialize_directives(&directives).expect("serialization failed");
577
578        // Deserialize
579        let deserialized = deserialize_directives(&serialized).expect("deserialization failed");
580
581        // Verify roundtrip
582        assert_eq!(directives.len(), deserialized.len());
583        let orig_txn = directives[0].value.as_transaction().unwrap();
584        let deser_txn = deserialized[0].value.as_transaction().unwrap();
585
586        assert_eq!(orig_txn.date, deser_txn.date);
587        assert_eq!(orig_txn.payee, deser_txn.payee);
588        assert_eq!(orig_txn.narration, deser_txn.narration);
589        assert_eq!(orig_txn.postings.len(), deser_txn.postings.len());
590
591        // Check first posting
592        assert_eq!(orig_txn.postings[0].account, deser_txn.postings[0].account);
593        assert_eq!(orig_txn.postings[0].units, deser_txn.postings[0].units);
594    }
595
596    #[test]
597    #[ignore = "manual benchmark - run with: cargo test -p rustledger-loader --release -- --ignored --nocapture"]
598    fn bench_cache_performance() {
599        // Generate test directives
600        let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
601        let mut directives = Vec::with_capacity(10000);
602
603        for i in 0..10000 {
604            let txn = Transaction::new(date, format!("Transaction {i}"))
605                .with_payee("Store")
606                .with_posting(Posting::new(
607                    "Expenses:Food",
608                    Amount::new(dec!(25.00), "USD"),
609                ))
610                .with_posting(Posting::auto("Assets:Checking"));
611
612            directives.push(Spanned::new(Directive::Transaction(txn), Span::new(0, 100)));
613        }
614
615        println!("\n=== Cache Benchmark (10,000 directives) ===");
616
617        // Benchmark serialization
618        let start = std::time::Instant::now();
619        let serialized = serialize_directives(&directives).unwrap();
620        let serialize_time = start.elapsed();
621        println!(
622            "Serialize: {:?} ({:.2} MB)",
623            serialize_time,
624            serialized.len() as f64 / 1_000_000.0
625        );
626
627        // Benchmark deserialization
628        let start = std::time::Instant::now();
629        let deserialized = deserialize_directives(&serialized).unwrap();
630        let deserialize_time = start.elapsed();
631        println!("Deserialize: {deserialize_time:?}");
632
633        assert_eq!(directives.len(), deserialized.len());
634
635        println!(
636            "\nSpeedup potential: If parsing takes 100ms, cache load would be {:.1}x faster",
637            100.0 / deserialize_time.as_millis() as f64
638        );
639    }
640
641    #[test]
642    fn test_cache_path() {
643        let source = Path::new("/tmp/ledger.beancount");
644        let cache = cache_path(source);
645        assert_eq!(cache, Path::new("/tmp/ledger.beancount.cache"));
646
647        let source2 = Path::new("relative/path/my.beancount");
648        let cache2 = cache_path(source2);
649        assert_eq!(cache2, Path::new("relative/path/my.beancount.cache"));
650    }
651
652    #[test]
653    fn test_save_load_cache_entry_roundtrip() {
654        use std::io::Write;
655
656        // Create a temp directory
657        let temp_dir = std::env::temp_dir().join("rustledger_cache_test");
658        let _ = fs::create_dir_all(&temp_dir);
659
660        // Create a temp beancount file
661        let beancount_file = temp_dir.join("test.beancount");
662        let mut f = fs::File::create(&beancount_file).unwrap();
663        writeln!(f, "2024-01-01 open Assets:Test").unwrap();
664        drop(f);
665
666        // Create a cache entry
667        let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
668        let txn = Transaction::new(date, "Test").with_posting(Posting::auto("Assets:Test"));
669        let directives = vec![Spanned::new(Directive::Transaction(txn), Span::new(0, 50))];
670
671        let entry = CacheEntry {
672            directives,
673            options: CachedOptions::from(&Options::new()),
674            plugins: vec![CachedPlugin {
675                name: "test_plugin".to_string(),
676                config: Some("config".to_string()),
677                force_python: false,
678            }],
679            files: vec![beancount_file.to_string_lossy().to_string()],
680        };
681
682        // Save cache
683        save_cache_entry(&beancount_file, &entry).expect("save failed");
684
685        // Load cache
686        let loaded = load_cache_entry(&beancount_file).expect("load failed");
687
688        // Verify
689        assert_eq!(loaded.directives.len(), entry.directives.len());
690        assert_eq!(loaded.plugins.len(), 1);
691        assert_eq!(loaded.plugins[0].name, "test_plugin");
692        assert_eq!(loaded.plugins[0].config, Some("config".to_string()));
693        assert_eq!(loaded.files.len(), 1);
694
695        // Cleanup
696        let _ = fs::remove_file(&beancount_file);
697        let _ = fs::remove_file(cache_path(&beancount_file));
698        let _ = fs::remove_dir(&temp_dir);
699    }
700
701    #[test]
702    fn test_invalidate_cache() {
703        use std::io::Write;
704
705        let temp_dir = std::env::temp_dir().join("rustledger_invalidate_test");
706        let _ = fs::create_dir_all(&temp_dir);
707
708        let beancount_file = temp_dir.join("test.beancount");
709        let mut f = fs::File::create(&beancount_file).unwrap();
710        writeln!(f, "2024-01-01 open Assets:Test").unwrap();
711        drop(f);
712
713        // Create and save a cache
714        let entry = CacheEntry {
715            directives: vec![],
716            options: CachedOptions::from(&Options::new()),
717            plugins: vec![],
718            files: vec![beancount_file.to_string_lossy().to_string()],
719        };
720        save_cache_entry(&beancount_file, &entry).unwrap();
721
722        // Verify cache exists
723        assert!(cache_path(&beancount_file).exists());
724
725        // Invalidate
726        invalidate_cache(&beancount_file);
727
728        // Verify cache is gone
729        assert!(!cache_path(&beancount_file).exists());
730
731        // Cleanup
732        let _ = fs::remove_file(&beancount_file);
733        let _ = fs::remove_dir(&temp_dir);
734    }
735
736    #[test]
737    fn test_load_cache_missing_file() {
738        let missing = Path::new("/nonexistent/path/to/file.beancount");
739        assert!(load_cache_entry(missing).is_none());
740    }
741
742    #[test]
743    fn test_load_cache_invalid_magic() {
744        use std::io::Write;
745
746        let temp_dir = std::env::temp_dir().join("rustledger_magic_test");
747        let _ = fs::create_dir_all(&temp_dir);
748
749        let cache_file = temp_dir.join("test.beancount.cache");
750        let mut f = fs::File::create(&cache_file).unwrap();
751        // Write invalid magic
752        f.write_all(b"INVALID\0").unwrap();
753        f.write_all(&[0u8; CacheHeader::SIZE - 8]).unwrap();
754        drop(f);
755
756        let beancount_file = temp_dir.join("test.beancount");
757        assert!(load_cache_entry(&beancount_file).is_none());
758
759        // Cleanup
760        let _ = fs::remove_file(&cache_file);
761        let _ = fs::remove_dir(&temp_dir);
762    }
763
764    #[test]
765    fn test_reintern_directives_deduplication() {
766        let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
767
768        // Create multiple transactions with the same account
769        let mut directives = vec![];
770        for i in 0..5 {
771            let txn = Transaction::new(date, format!("Txn {i}"))
772                .with_posting(Posting::new(
773                    "Expenses:Food",
774                    Amount::new(dec!(10.00), "USD"),
775                ))
776                .with_posting(Posting::auto("Assets:Checking"));
777            directives.push(Spanned::new(Directive::Transaction(txn), Span::new(0, 50)));
778        }
779
780        // Re-intern should deduplicate the repeated account names and currencies
781        let dedup_count = reintern_directives(&mut directives);
782
783        // We should have deduplicated:
784        // - "Expenses:Food" appears 5 times but only first is new (4 dedup)
785        // - "USD" appears 5 times but only first is new (4 dedup)
786        // - "Assets:Checking" appears 5 times but only first is new (4 dedup)
787        // Total: 12 deduplications
788        assert_eq!(dedup_count, 12);
789    }
790
791    #[test]
792    fn test_cached_options_roundtrip() {
793        let mut opts = Options::new();
794        opts.title = Some("Test Ledger".to_string());
795        opts.operating_currency = vec!["USD".to_string(), "EUR".to_string()];
796        opts.render_commas = true;
797
798        let cached = CachedOptions::from(&opts);
799        let restored: Options = cached.into();
800
801        assert_eq!(restored.title, Some("Test Ledger".to_string()));
802        assert_eq!(restored.operating_currency, vec!["USD", "EUR"]);
803        assert!(restored.render_commas);
804    }
805
806    #[test]
807    fn test_cache_entry_file_paths() {
808        let entry = CacheEntry {
809            directives: vec![],
810            options: CachedOptions::from(&Options::new()),
811            plugins: vec![],
812            files: vec![
813                "/path/to/ledger.beancount".to_string(),
814                "/path/to/include.beancount".to_string(),
815            ],
816        };
817
818        let paths = entry.file_paths();
819        assert_eq!(paths.len(), 2);
820        assert_eq!(paths[0], PathBuf::from("/path/to/ledger.beancount"));
821        assert_eq!(paths[1], PathBuf::from("/path/to/include.beancount"));
822    }
823
824    #[test]
825    fn test_reintern_balance_directive() {
826        use rustledger_core::Balance;
827
828        let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
829        let balance = Balance::new(date, "Assets:Checking", Amount::new(dec!(1000.00), "USD"));
830
831        let mut directives = vec![
832            Spanned::new(Directive::Balance(balance.clone()), Span::new(0, 50)),
833            Spanned::new(Directive::Balance(balance), Span::new(51, 100)),
834        ];
835
836        let dedup_count = reintern_directives(&mut directives);
837        // Second occurrence of "Assets:Checking" and "USD" should be deduplicated
838        assert_eq!(dedup_count, 2);
839    }
840
841    #[test]
842    fn test_reintern_open_close_directives() {
843        use rustledger_core::{Close, Open};
844
845        let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
846        let open = Open::new(date, "Assets:Checking");
847        let close = Close::new(date, "Assets:Checking");
848
849        let mut directives = vec![
850            Spanned::new(Directive::Open(open), Span::new(0, 50)),
851            Spanned::new(Directive::Close(close), Span::new(51, 100)),
852        ];
853
854        let dedup_count = reintern_directives(&mut directives);
855        // Second "Assets:Checking" should be deduplicated
856        assert_eq!(dedup_count, 1);
857    }
858}