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//! By default, cache files are stored alongside the main ledger as a hidden
17//! dotfile: `ledger.beancount` → `.ledger.beancount.cache`. This matches Python
18//! beancount's `.{filename}.picklecache` convention.
19//!
20//! Two environment variables control the location, both compatible with
21//! Python beancount and honored at the loader level (so any consumer of
22//! [`load_cache_entry`] / [`save_cache_entry`] gets the kill switch for free):
23//!
24//! - `BEANCOUNT_DISABLE_LOAD_CACHE`: when set (even to an empty value),
25//!   [`load_cache_entry`] returns `None` and [`save_cache_entry`] is a no-op.
26//! - `BEANCOUNT_LOAD_CACHE_FILENAME`: a path pattern that may contain
27//!   `{filename}` (replaced with the source basename). Relative paths resolve
28//!   against the source directory; absolute paths are used as-is. If the
29//!   target directory doesn't exist, [`save_cache_entry`] creates it.
30
31use 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/// Cached plugin information.
42#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
43pub struct CachedPlugin {
44    /// Plugin module name.
45    pub name: String,
46    /// Optional configuration string.
47    pub config: Option<String>,
48    /// Whether the `python:` prefix was used to force Python execution.
49    pub force_python: bool,
50}
51
52/// Cached options - a serializable subset of Options.
53///
54/// Excludes transient parsing-time fields like `warnings`, but DOES
55/// persist `set_options`: it is load-bearing downstream, because
56/// `resolve_effective_booking_method` gates on
57/// `set_options.contains("booking_method")` to decide whether the
58/// file-level `option "booking_method"` wins over the API default.
59/// Dropping it across the cache round-trip silently re-books FIFO/LIFO
60/// ledgers as STRICT on a cache hit (#1340).
61/// These fields mirror the Options struct and inherit their meaning.
62#[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    /// Stored as (currency, `tolerance_string`) pairs since Decimal needs special handling
82    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    /// `option "display_precision" "USD:0.0001"` overrides (the digit count is
91    /// the example number's decimal scale, so `0.0001` → 4), stored as
92    /// (currency, digits) pairs. Dropping this on a cache hit silently reverted
93    /// number formatting to inferred precision (the bug this field fixes).
94    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    /// Names of options the source explicitly set (e.g.
101    /// `"booking_method"`). Restored so downstream resolution that
102    /// distinguishes "file set this" from "inherited default" behaves
103    /// identically on a cache hit. See the struct-level note (#1340).
104    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/// Complete cache entry containing all data needed to restore a `LoadResult`.
202#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
203pub struct CacheEntry {
204    /// All parsed directives.
205    pub directives: Vec<Spanned<Directive>>,
206    /// Parsed options.
207    pub options: CachedOptions,
208    /// Plugin declarations.
209    pub plugins: Vec<CachedPlugin>,
210    /// All files that were loaded (as strings, for serialization).
211    pub files: Vec<String>,
212}
213
214impl CacheEntry {
215    /// Get files as `PathBuf` references.
216    pub fn file_paths(&self) -> Vec<PathBuf> {
217        self.files.iter().map(PathBuf::from).collect()
218    }
219
220    /// Reconstruct a [`LoadResult`](crate::LoadResult) equivalent to a
221    /// fresh parse of the cached source.
222    ///
223    /// Re-reads each cached source file for the source map (so error
224    /// reporting still has text), converts the cached plugin
225    /// declarations back (their span / `file_id` are not meaningful
226    /// from cache), and — crucially — rebuilds the display context from
227    /// the cached directives + options via the same inference a fresh
228    /// load uses, so a cache-hit `LoadResult` formats numbers
229    /// identically to an uncached one. Reconstructing it as an empty
230    /// `DisplayContext` (as the per-command CLI code used to) would
231    /// silently change per-currency display precision for any consumer
232    /// that reads it.
233    ///
234    /// `errors` is empty by construction: the cache is only written for
235    /// error-free, warning-free loads.
236    ///
237    /// Strings are NOT re-interned here; a caller that wants the memory
238    /// dedup should call [`crate::reintern_directives`] on
239    /// `self.directives` first (it needs `&mut`).
240    #[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            // Read bytes + lossy UTF-8 to match `DiskFileSystem::read`
245            // (the uncached loader path). `read_to_string` would error
246            // and silently skip a non-UTF8 source file, leaving the
247            // cache-hit source map missing text the uncached run has -
248            // an error-reporting parity gap.
249            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
281/// Magic bytes to identify cache files.
282const CACHE_MAGIC: &[u8; 8] = b"RLEDGER\0";
283
284/// Cache version - increment when format changes.
285/// v1: Initial release with string-based Decimal/NaiveDate
286/// v2: Binary Decimal (16 bytes) and `NaiveDate` (i32 days)
287/// v3: Fixed account type defaults in `CachedOptions`
288/// v4: Hash algorithm switched from SHA-256 to BLAKE3 — same 32-byte
289///     output so the header layout is unchanged, but old hashes won't
290///     match new files. Bumping the version short-circuits stale
291///     caches at the header check instead of paying the rkyv
292///     deserialize cost only to fail the hash compare.
293/// v5: `Transaction.postings: Vec<Posting>` became
294///     `Vec<Spanned<Posting>>` (#1151). The inner posting bytes
295///     gained a `Span + file_id` per entry, so old cache files
296///     would rkyv-deserialize into the new type as junk. Header
297///     check forces a rebuild instead.
298/// v6: The #1163 newtype slices (#1169 `Currency`, #1171 `Account`,
299///     #1172 `Tag`, #1173 `Link`, #1174 `MetaValue`) swapped variant
300///     payload types from `InternedStr`/`String` to typed newtypes.
301///     The archived layout coincidentally matches `AsInternedStr`
302///     in most cases, but `MetaValue::{Account,Currency,Tag,Link}`
303///     and `Transaction.tags`/`links` (plus the parallel `Document`
304///     fields) changed their archive wrappers. Bumping the version
305///     forces regeneration so we don't risk rkyv reading old bytes
306///     into a structurally-different `ArchivedMetaValue`.
307/// v7: `PriceAnnotation` refactored from 6-variant enum to
308///     `{ kind: PriceKind, amount: Option<IncompleteAmount> }`
309///     (#1167). Old cache bytes for the enum's discriminant would
310///     deserialize as nonsense in the new struct layout.
311/// v8: `CostSpec.{number_per,number_total}: Option<Decimal>` collapsed
312///     into `CostSpec.number: Option<CostNumber>` where `CostNumber` is
313///     a 3-variant enum (`PerUnit`, `Total`, `PerUnitFromTotal`)
314///     (#1164). The archived layout is structurally different
315///     (Option<Decimal> + Option<Decimal> → Option<discriminant +
316///     payload>); reading v7 bytes into the v8 layout would produce
317///     garbage cost numbers. Bumping forces regeneration.
318///     Subsequent #1164 follow-up commits converted `CostNumber`'s
319///     variants from tuple form (`PerUnit(Decimal)`) to struct form
320///     (`PerUnit { value: Decimal }`) so serde could apply
321///     `tag = "kind"` for cross-boundary wire unification. The rkyv-
322///     archived layout for a single-field struct variant is byte-
323///     identical to the tuple variant (both pack `Archived<Decimal>`
324///     positionally) — verified against rkyv 0.8.16 — so this change
325///     does NOT require a separate version bump. If a future rkyv
326///     version changes that encoding, OR if `CostNumber` gains
327///     additional fields, bump `CACHE_VERSION` to the next value.
328/// v13: `CostNumber` gained the `Compound { per_unit, total }` variant
329///     (#1700) and the parser now emits it for `{a # b}` cost specs —
330///     exactly the "gains additional fields" case the v12 note calls
331///     out. Without the bump, a cache written by a pre-#1700 binary
332///     serves the old misparse (`Total{b}` / `PerUnit{b}`) to fixed
333///     binaries, resurrecting the bug for any previously-loaded ledger.
334/// v14: green compound-cost conversion now retries past unparsable
335///     pre-/post-hash NUMBER tokens like red (#1713); inputs with garbage
336///     around `{a # b}` parse to different `CostNumber` values than v13
337///     cached them as.
338/// v9: `CachedOptions` gained a `set_options: Vec<String>` field
339///     (#1340). It was previously dropped, so a cache hit lost the
340///     record of which options the file explicitly set — making
341///     `resolve_effective_booking_method` re-book FIFO/LIFO ledgers as
342///     STRICT. The new trailing field changes the archived layout, so
343///     old bytes must be regenerated.
344/// v10: String literals are now escape-decoded at parse (`\"`->`"`, etc.);
345///     the stored narration/payee/meta/etc. bytes differ from the old raw
346///     form, so a cache hit would serve stale, still-escaped strings.
347/// v11: `MetaValue` gained an `Int(i64)` variant (appended last). Integer
348///     metadata literals (`key: 42`) now archive as `Int` rather than
349///     `Number`, and the new discriminant changes the enum's archived
350///     layout, so old bytes must be regenerated.
351/// v12: `CachedOptions` gained `display_precision`, `use_precise_interpolation`,
352///     and `plugin_processing_mode` — previously dropped, so a cache hit
353///     silently ignored `option "display_precision" "USD:0.0001"` (formatting
354///     fell back to inferred precision) and the other two settings. New fields
355///     change the archived layout, so old bytes must be regenerated.
356const CACHE_VERSION: u32 = 14;
357
358/// Cache header stored at the start of cache files.
359#[derive(Debug, Clone)]
360struct CacheHeader {
361    /// Magic bytes for identification.
362    magic: [u8; 8],
363    /// Cache format version.
364    version: u32,
365    /// BLAKE3 hash of source files (path + mtime + size).
366    hash: [u8; 32],
367    /// Length of the serialized data.
368    data_len: u64,
369}
370
371impl CacheHeader {
372    const SIZE: usize = 8 + 4 + 32 + 8;
373
374    fn to_bytes(&self) -> [u8; Self::SIZE] {
375        let mut buf = [0u8; Self::SIZE];
376        buf[0..8].copy_from_slice(&self.magic);
377        buf[8..12].copy_from_slice(&self.version.to_le_bytes());
378        buf[12..44].copy_from_slice(&self.hash);
379        buf[44..52].copy_from_slice(&self.data_len.to_le_bytes());
380        buf
381    }
382
383    fn from_bytes(bytes: &[u8]) -> Option<Self> {
384        if bytes.len() < Self::SIZE {
385            return None;
386        }
387
388        let mut magic = [0u8; 8];
389        magic.copy_from_slice(&bytes[0..8]);
390
391        let version = u32::from_le_bytes(bytes[8..12].try_into().ok()?);
392
393        let mut hash = [0u8; 32];
394        hash.copy_from_slice(&bytes[12..44]);
395
396        let data_len = u64::from_le_bytes(bytes[44..52].try_into().ok()?);
397
398        Some(Self {
399            magic,
400            version,
401            hash,
402            data_len,
403        })
404    }
405}
406
407/// Compute a hash of the given files and their modification times.
408///
409/// Files whose metadata cannot be read (e.g., deleted between load and cache)
410/// contribute only their path to the hash. This is intentional — the resulting
411/// hash mismatch will cause a cache miss on next load.
412fn compute_hash(files: &[&Path]) -> [u8; 32] {
413    let mut hasher = Hasher::new();
414
415    for file in files {
416        // Hash the file path
417        hasher.update(file.to_string_lossy().as_bytes());
418
419        // Hash the modification time (skip silently if inaccessible)
420        if let Ok(metadata) = fs::metadata(file) {
421            if let Ok(mtime) = metadata.modified()
422                && let Ok(duration) = mtime.duration_since(std::time::UNIX_EPOCH)
423            {
424                hasher.update(&duration.as_secs().to_le_bytes());
425                hasher.update(&duration.subsec_nanos().to_le_bytes());
426            }
427            // Hash the file size
428            hasher.update(&metadata.len().to_le_bytes());
429        }
430    }
431
432    *hasher.finalize().as_bytes()
433}
434
435/// Environment variable that overrides the default cache filename pattern.
436///
437/// The value is a path that may contain `{filename}` as a placeholder for the
438/// source file's basename. Relative paths are resolved against the source
439/// file's directory; absolute paths are used as-is. Mirrors Python beancount's
440/// `BEANCOUNT_LOAD_CACHE_FILENAME`.
441pub const CACHE_FILENAME_ENV: &str = "BEANCOUNT_LOAD_CACHE_FILENAME";
442
443/// Environment variable that disables the binary cache entirely when set.
444///
445/// Mirrors Python beancount's `BEANCOUNT_DISABLE_LOAD_CACHE`.
446pub const DISABLE_CACHE_ENV: &str = "BEANCOUNT_DISABLE_LOAD_CACHE";
447
448/// Returns the cache file path for a given source file.
449///
450/// Resolution order:
451/// 1. If `BEANCOUNT_LOAD_CACHE_FILENAME` is set, substitute `{filename}` with
452///    the source basename and resolve relative paths against the source dir.
453/// 2. Otherwise, default to a hidden dotfile alongside the source via
454///    [`default_cache_path`]: `path/to/main.beancount` →
455///    `path/to/.main.beancount.cache`.
456///
457/// The dotfile prefix matches Python beancount's `.{filename}.picklecache`
458/// convention, so the cache stays out of the way of `ls` and most file
459/// explorers without breaking from the established beancount ecosystem
460/// behavior. See issue #939.
461///
462/// This function reads process env. Tests that need a deterministic path
463/// regardless of the caller's environment should use [`default_cache_path`]
464/// directly.
465pub fn cache_path(source: &Path) -> PathBuf {
466    if let Ok(pattern) = std::env::var(CACHE_FILENAME_ENV)
467        && !pattern.is_empty()
468    {
469        return resolve_cache_pattern(source, &pattern);
470    }
471    default_cache_path(source)
472}
473
474/// Returns the default cache file path (no env-var lookup).
475///
476/// Use this when you need a path that is independent of process env, e.g.
477/// in tests that mustn't be perturbed by a developer's
478/// `BEANCOUNT_LOAD_CACHE_FILENAME`.
479#[must_use]
480pub fn default_cache_path(source: &Path) -> PathBuf {
481    let mut path = source.to_path_buf();
482    let name = path.file_name().map_or_else(
483        || ".ledger.cache".to_string(),
484        |n| format!(".{}.cache", n.to_string_lossy()),
485    );
486    path.set_file_name(name);
487    path
488}
489
490/// Resolve a `BEANCOUNT_LOAD_CACHE_FILENAME` pattern against a source path.
491///
492/// The `"{filename}"` token below is a literal user-facing substitution
493/// placeholder (matching Python beancount), not a `format!` argument — hence
494/// the explicit allow.
495#[allow(clippy::literal_string_with_formatting_args)]
496fn resolve_cache_pattern(source: &Path, pattern: &str) -> PathBuf {
497    let filename = source.file_name().map_or_else(
498        || "ledger".to_string(),
499        |n| n.to_string_lossy().into_owned(),
500    );
501    let resolved = pattern.replace("{filename}", &filename);
502    let p = PathBuf::from(&resolved);
503    if p.is_absolute() {
504        return p;
505    }
506    source.parent().map_or(p.clone(), |parent| parent.join(&p))
507}
508
509/// Returns the legacy (pre-#939) cache path: `<source>.cache` alongside source.
510///
511/// Used by `save_cache_entry` to opportunistically clean up stale cache files
512/// from earlier rustledger versions. Not part of the lookup path.
513fn legacy_cache_path(source: &Path) -> PathBuf {
514    let mut path = source.to_path_buf();
515    let name = path.file_name().map_or_else(
516        || "ledger.cache".to_string(),
517        |n| format!("{}.cache", n.to_string_lossy()),
518    );
519    path.set_file_name(name);
520    path
521}
522
523/// Returns true if `BEANCOUNT_DISABLE_LOAD_CACHE` is set in the environment.
524///
525/// Mere presence disables — value is ignored, including empty string. Matches
526/// Python beancount's `os.getenv("BEANCOUNT_DISABLE_LOAD_CACHE") is None`
527/// check.
528#[must_use]
529pub fn cache_disabled_by_env() -> bool {
530    std::env::var_os(DISABLE_CACHE_ENV).is_some()
531}
532
533/// Try to load a cache entry from disk.
534///
535/// Returns `Some(CacheEntry)` if cache is valid and file hashes match,
536/// `None` if cache is missing, invalid, outdated, or
537/// `BEANCOUNT_DISABLE_LOAD_CACHE` is set.
538pub fn load_cache_entry(main_file: &Path) -> Option<CacheEntry> {
539    if cache_disabled_by_env() {
540        return None;
541    }
542    let cache_file = cache_path(main_file);
543    let mut file = fs::File::open(&cache_file).ok()?;
544
545    // Read header
546    let mut header_bytes = [0u8; CacheHeader::SIZE];
547    file.read_exact(&mut header_bytes).ok()?;
548    let header = CacheHeader::from_bytes(&header_bytes)?;
549
550    // Validate magic and version
551    if header.magic != *CACHE_MAGIC {
552        return None;
553    }
554    if header.version != CACHE_VERSION {
555        return None;
556    }
557
558    // Read data
559    let mut data = vec![0u8; header.data_len as usize];
560    file.read_exact(&mut data).ok()?;
561
562    // Deserialize
563    let entry: CacheEntry = rkyv::from_bytes::<CacheEntry, rkyv::rancor::Error>(&data).ok()?;
564
565    // Validate hash against the files stored in the cache
566    let file_paths = entry.file_paths();
567    let file_refs: Vec<&Path> = file_paths.iter().map(PathBuf::as_path).collect();
568    let expected_hash = compute_hash(&file_refs);
569    if header.hash != expected_hash {
570        return None;
571    }
572
573    Some(entry)
574}
575
576/// Save a cache entry to disk.
577///
578/// No-op (returns Ok) when `BEANCOUNT_DISABLE_LOAD_CACHE` is set.
579pub fn save_cache_entry(main_file: &Path, entry: &CacheEntry) -> Result<(), std::io::Error> {
580    if cache_disabled_by_env() {
581        return Ok(());
582    }
583    let cache_file = cache_path(main_file);
584
585    // Compute hash from the files in the entry
586    let file_paths = entry.file_paths();
587    let file_refs: Vec<&Path> = file_paths.iter().map(PathBuf::as_path).collect();
588    let hash = compute_hash(&file_refs);
589
590    // Serialize
591    let data = rkyv::to_bytes::<rkyv::rancor::Error>(entry)
592        .map(|v| v.to_vec())
593        .map_err(|e| std::io::Error::other(e.to_string()))?;
594
595    // Write header + data
596    let header = CacheHeader {
597        magic: *CACHE_MAGIC,
598        version: CACHE_VERSION,
599        hash,
600        data_len: data.len() as u64,
601    };
602
603    // Custom BEANCOUNT_LOAD_CACHE_FILENAME patterns can point at a directory
604    // that doesn't exist yet (e.g. ~/.cache/rledger/foo.cache on a fresh
605    // install). Create the parent eagerly so caching isn't silently disabled.
606    if let Some(parent) = cache_file.parent()
607        && !parent.as_os_str().is_empty()
608    {
609        fs::create_dir_all(parent)?;
610    }
611
612    let mut file = fs::File::create(&cache_file)?;
613    file.write_all(&header.to_bytes())?;
614    file.write_all(&data)?;
615
616    // One-shot cleanup of pre-#939 visible cache files. Only attempt when the
617    // legacy path differs from the new path (i.e., we're not using a custom
618    // pattern that happens to land on the old name) and silently ignore
619    // failures — leaving the file is harmless, just untidy.
620    let legacy = legacy_cache_path(main_file);
621    if legacy != cache_file && legacy.exists() {
622        let _ = fs::remove_file(&legacy);
623    }
624
625    Ok(())
626}
627
628/// Serialize directives to bytes using rkyv (for benchmarking).
629#[cfg(test)]
630fn serialize_directives(directives: &Vec<Spanned<Directive>>) -> Result<Vec<u8>, std::io::Error> {
631    rkyv::to_bytes::<rkyv::rancor::Error>(directives)
632        .map(|v| v.to_vec())
633        .map_err(|e| std::io::Error::other(e.to_string()))
634}
635
636/// Deserialize directives from bytes using rkyv (for benchmarking).
637#[cfg(test)]
638fn deserialize_directives(data: &[u8]) -> Option<Vec<Spanned<Directive>>> {
639    rkyv::from_bytes::<Vec<Spanned<Directive>>, rkyv::rancor::Error>(data).ok()
640}
641
642/// Invalidate the cache for a file.
643///
644/// Removes both the current cache file and any legacy pre-#939
645/// `<file>.cache` sidecar so a subsequent load can't pick up stale data.
646pub fn invalidate_cache(main_file: &Path) {
647    let cache_file = cache_path(main_file);
648    let _ = fs::remove_file(&cache_file);
649
650    let legacy = legacy_cache_path(main_file);
651    if legacy != cache_file {
652        let _ = fs::remove_file(&legacy);
653    }
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659    use crate::dedup::reintern_directives;
660    use rust_decimal_macros::dec;
661    use rustledger_core::{Amount, Posting, Transaction};
662    use rustledger_parser::Span;
663
664    #[test]
665    fn test_cache_header_roundtrip() {
666        let header = CacheHeader {
667            magic: *CACHE_MAGIC,
668            version: CACHE_VERSION,
669            hash: [42u8; 32],
670            data_len: 12345,
671        };
672
673        let bytes = header.to_bytes();
674        let parsed = CacheHeader::from_bytes(&bytes).unwrap();
675
676        assert_eq!(parsed.magic, header.magic);
677        assert_eq!(parsed.version, header.version);
678        assert_eq!(parsed.hash, header.hash);
679        assert_eq!(parsed.data_len, header.data_len);
680    }
681
682    #[test]
683    fn test_compute_hash_deterministic() {
684        let files: Vec<&Path> = vec![];
685        let hash1 = compute_hash(&files);
686        let hash2 = compute_hash(&files);
687        assert_eq!(hash1, hash2);
688    }
689
690    #[test]
691    fn test_serialize_deserialize_roundtrip() {
692        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
693
694        let txn = Transaction::new(date, "Test transaction")
695            .with_payee("Test Payee")
696            .with_synthesized_posting(Posting::new(
697                "Expenses:Test",
698                Amount::new(dec!(100.00), "USD"),
699            ))
700            .with_synthesized_posting(Posting::auto("Assets:Checking"));
701
702        let directives = vec![Spanned::new(Directive::Transaction(txn), Span::new(0, 100))];
703
704        // Serialize
705        let serialized = serialize_directives(&directives).expect("serialization failed");
706
707        // Deserialize
708        let deserialized = deserialize_directives(&serialized).expect("deserialization failed");
709
710        // Verify roundtrip
711        assert_eq!(directives.len(), deserialized.len());
712        let orig_txn = directives[0].value.as_transaction().unwrap();
713        let deser_txn = deserialized[0].value.as_transaction().unwrap();
714
715        assert_eq!(orig_txn.date, deser_txn.date);
716        assert_eq!(orig_txn.payee, deser_txn.payee);
717        assert_eq!(orig_txn.narration, deser_txn.narration);
718        assert_eq!(orig_txn.postings.len(), deser_txn.postings.len());
719
720        // Check first posting
721        assert_eq!(orig_txn.postings[0].account, deser_txn.postings[0].account);
722        assert_eq!(orig_txn.postings[0].units, deser_txn.postings[0].units);
723    }
724
725    #[test]
726    #[ignore = "manual benchmark - run with: cargo test -p rustledger-loader --release -- --ignored --nocapture"]
727    fn bench_cache_performance() {
728        // Generate test directives
729        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
730        let mut directives = Vec::with_capacity(10000);
731
732        for i in 0..10000 {
733            let txn = Transaction::new(date, format!("Transaction {i}"))
734                .with_payee("Store")
735                .with_synthesized_posting(Posting::new(
736                    "Expenses:Food",
737                    Amount::new(dec!(25.00), "USD"),
738                ))
739                .with_synthesized_posting(Posting::auto("Assets:Checking"));
740
741            directives.push(Spanned::new(Directive::Transaction(txn), Span::new(0, 100)));
742        }
743
744        println!("\n=== Cache Benchmark (10,000 directives) ===");
745
746        // Benchmark serialization
747        let start = std::time::Instant::now();
748        let serialized = serialize_directives(&directives).unwrap();
749        let serialize_time = start.elapsed();
750        println!(
751            "Serialize: {:?} ({:.2} MB)",
752            serialize_time,
753            serialized.len() as f64 / 1_000_000.0
754        );
755
756        // Benchmark deserialization
757        let start = std::time::Instant::now();
758        let deserialized = deserialize_directives(&serialized).unwrap();
759        let deserialize_time = start.elapsed();
760        println!("Deserialize: {deserialize_time:?}");
761
762        assert_eq!(directives.len(), deserialized.len());
763
764        println!(
765            "\nSpeedup potential: If parsing takes 100ms, cache load would be {:.1}x faster",
766            100.0 / deserialize_time.as_millis() as f64
767        );
768    }
769
770    // Note: end-to-end coverage of `cache_path()` (including the
771    // `BEANCOUNT_LOAD_CACHE_FILENAME` env var) lives in
772    // `tests/cache_env_var_test.rs`, which can mutate process env without
773    // tripping the crate's `forbid(unsafe_code)`. The tests below cover the
774    // pure pattern-resolution logic and the legacy-path helper.
775
776    /// Fail fast if a developer has set the cache env vars locally — the
777    /// roundtrip tests in this module call `save_cache_entry`/`invalidate_cache`
778    /// which read process env, and a custom pattern would silently redirect
779    /// writes elsewhere (or fail in surprising ways). CI runs with a clean env.
780    fn assert_clean_cache_env() {
781        for var in [CACHE_FILENAME_ENV, DISABLE_CACHE_ENV] {
782            assert!(
783                std::env::var_os(var).is_none(),
784                "unset {var} before running this test"
785            );
786        }
787    }
788
789    #[test]
790    fn test_resolve_cache_pattern_relative_with_substitution() {
791        let source = Path::new("/home/user/finances/main.beancount");
792        let resolved = resolve_cache_pattern(source, ".cache/{filename}.bin");
793        assert_eq!(
794            resolved,
795            Path::new("/home/user/finances/.cache/main.beancount.bin")
796        );
797    }
798
799    #[test]
800    fn test_resolve_cache_pattern_absolute() {
801        let source = Path::new("/home/user/main.beancount");
802        let resolved = resolve_cache_pattern(source, "/var/cache/rledger/{filename}.cache");
803        assert_eq!(
804            resolved,
805            Path::new("/var/cache/rledger/main.beancount.cache")
806        );
807    }
808
809    #[test]
810    fn test_resolve_cache_pattern_no_substitution() {
811        // Pattern without {filename} is used verbatim.
812        let source = Path::new("/home/user/main.beancount");
813        let resolved = resolve_cache_pattern(source, "fixed.cache");
814        assert_eq!(resolved, Path::new("/home/user/fixed.cache"));
815    }
816
817    #[test]
818    fn test_legacy_cache_path() {
819        let source = Path::new("/tmp/ledger.beancount");
820        assert_eq!(
821            legacy_cache_path(source),
822            Path::new("/tmp/ledger.beancount.cache")
823        );
824    }
825
826    #[test]
827    fn test_save_load_cache_entry_roundtrip() {
828        use std::io::Write;
829
830        assert_clean_cache_env();
831
832        // Create a temp directory
833        let temp_dir = std::env::temp_dir().join("rustledger_cache_test");
834        let _ = fs::create_dir_all(&temp_dir);
835
836        // Create a temp beancount file
837        let beancount_file = temp_dir.join("test.beancount");
838        let mut f = fs::File::create(&beancount_file).unwrap();
839        writeln!(f, "2024-01-01 open Assets:Test").unwrap();
840        drop(f);
841
842        // Create a cache entry
843        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
844        let txn =
845            Transaction::new(date, "Test").with_synthesized_posting(Posting::auto("Assets:Test"));
846        let directives = vec![Spanned::new(Directive::Transaction(txn), Span::new(0, 50))];
847
848        let entry = CacheEntry {
849            directives,
850            options: CachedOptions::from(&Options::new()),
851            plugins: vec![CachedPlugin {
852                name: "test_plugin".to_string(),
853                config: Some("config".to_string()),
854                force_python: false,
855            }],
856            files: vec![beancount_file.to_string_lossy().to_string()],
857        };
858
859        // Save cache
860        save_cache_entry(&beancount_file, &entry).expect("save failed");
861
862        // Load cache
863        let loaded = load_cache_entry(&beancount_file).expect("load failed");
864
865        // Verify
866        assert_eq!(loaded.directives.len(), entry.directives.len());
867        assert_eq!(loaded.plugins.len(), 1);
868        assert_eq!(loaded.plugins[0].name, "test_plugin");
869        assert_eq!(loaded.plugins[0].config, Some("config".to_string()));
870        assert_eq!(loaded.files.len(), 1);
871
872        // Cleanup
873        let _ = fs::remove_file(&beancount_file);
874        let _ = fs::remove_file(cache_path(&beancount_file));
875        let _ = fs::remove_dir(&temp_dir);
876    }
877
878    #[test]
879    fn test_invalidate_cache() {
880        use std::io::Write;
881
882        assert_clean_cache_env();
883
884        let temp_dir = std::env::temp_dir().join("rustledger_invalidate_test");
885        let _ = fs::create_dir_all(&temp_dir);
886
887        let beancount_file = temp_dir.join("test.beancount");
888        let mut f = fs::File::create(&beancount_file).unwrap();
889        writeln!(f, "2024-01-01 open Assets:Test").unwrap();
890        drop(f);
891
892        // Create and save a cache
893        let entry = CacheEntry {
894            directives: vec![],
895            options: CachedOptions::from(&Options::new()),
896            plugins: vec![],
897            files: vec![beancount_file.to_string_lossy().to_string()],
898        };
899        save_cache_entry(&beancount_file, &entry).unwrap();
900
901        // Verify cache exists
902        assert!(cache_path(&beancount_file).exists());
903
904        // Invalidate
905        invalidate_cache(&beancount_file);
906
907        // Verify cache is gone
908        assert!(!cache_path(&beancount_file).exists());
909
910        // Cleanup
911        let _ = fs::remove_file(&beancount_file);
912        let _ = fs::remove_dir(&temp_dir);
913    }
914
915    #[test]
916    fn test_invalidate_cache_removes_legacy_sidecar() {
917        // invalidate_cache should remove both the new dotfile cache and any
918        // pre-#939 visible cache file alongside the source.
919        assert_clean_cache_env();
920
921        let temp_dir = std::env::temp_dir().join("rustledger_invalidate_legacy_test");
922        let _ = fs::create_dir_all(&temp_dir);
923
924        let beancount_file = temp_dir.join("legacy.beancount");
925        // Synthesize a leftover legacy cache file (no need to be valid — we're
926        // only testing that invalidate removes it).
927        let legacy = legacy_cache_path(&beancount_file);
928        fs::write(&legacy, b"stale").unwrap();
929        assert!(legacy.exists());
930
931        invalidate_cache(&beancount_file);
932        assert!(
933            !legacy.exists(),
934            "invalidate_cache should remove the legacy sidecar file"
935        );
936
937        let _ = fs::remove_dir(&temp_dir);
938    }
939
940    #[test]
941    fn test_load_cache_missing_file() {
942        let missing = Path::new("/nonexistent/path/to/file.beancount");
943        assert!(load_cache_entry(missing).is_none());
944    }
945
946    #[test]
947    fn test_load_cache_invalid_magic() {
948        use std::io::Write;
949
950        assert_clean_cache_env();
951
952        let temp_dir = std::env::temp_dir().join("rustledger_magic_test");
953        let _ = fs::create_dir_all(&temp_dir);
954
955        let beancount_file = temp_dir.join("test.beancount");
956        // Write a malformed cache file at the path load_cache_entry will look up.
957        let cache_file = cache_path(&beancount_file);
958        let mut f = fs::File::create(&cache_file).unwrap();
959        // Write invalid magic
960        f.write_all(b"INVALID\0").unwrap();
961        f.write_all(&[0u8; CacheHeader::SIZE - 8]).unwrap();
962        drop(f);
963
964        assert!(load_cache_entry(&beancount_file).is_none());
965
966        // Cleanup
967        let _ = fs::remove_file(&cache_file);
968        let _ = fs::remove_dir(&temp_dir);
969    }
970
971    /// Bumping `CACHE_VERSION` must short-circuit at the header so we
972    /// never feed an older payload to rkyv with the newer schema. Writes
973    /// a header with the correct magic but `version = CACHE_VERSION - 1`
974    /// (e.g., v4 from before #1151's `Vec<Spanned<Posting>>` shape
975    /// change) and asserts the loader refuses it.
976    #[test]
977    fn test_load_cache_rejects_older_version() {
978        use std::io::Write;
979
980        assert_clean_cache_env();
981
982        let temp_dir = std::env::temp_dir().join("rustledger_old_version_test");
983        let _ = fs::create_dir_all(&temp_dir);
984
985        let beancount_file = temp_dir.join("test.beancount");
986        let cache_file = cache_path(&beancount_file);
987        let mut f = fs::File::create(&cache_file).unwrap();
988
989        // Valid magic + previous CACHE_VERSION. The version check at
990        // `load_cache_header` should refuse before any payload is
991        // touched, no matter what the tail bytes look like.
992        let stale_version: u32 = CACHE_VERSION.checked_sub(1).expect("CACHE_VERSION >= 1");
993        f.write_all(CACHE_MAGIC).unwrap();
994        f.write_all(&stale_version.to_le_bytes()).unwrap();
995        f.write_all(&[0u8; CacheHeader::SIZE - 8 - 4]).unwrap();
996        drop(f);
997
998        assert!(
999            load_cache_entry(&beancount_file).is_none(),
1000            "loader must reject cache files with an older CACHE_VERSION"
1001        );
1002
1003        let _ = fs::remove_file(&cache_file);
1004        let _ = fs::remove_dir(&temp_dir);
1005    }
1006
1007    /// Frozen byte fixtures for the v8 cache layout of
1008    /// [`rustledger_core::CostNumber`].
1009    ///
1010    /// The intra-build distinctness test in `rustledger-core::cost`
1011    /// (`cost_number_archived_bytes_snapshot`) only catches drift
1012    /// where variants collide with each other. It would NOT catch a
1013    /// uniform encoding shift (e.g. a future rkyv minor bump that
1014    /// changes how `Archived<Decimal>` packs, or an accidental
1015    /// attribute change). When that happens every variant moves
1016    /// together so distinctness still holds, but user caches on disk
1017    /// silently fail to deserialize as garbage in the new layout.
1018    ///
1019    /// Capturing the exact bytes here pins the on-disk contract:
1020    /// any drift trips this test, forcing the developer to either
1021    /// (a) revert the encoding change, or (b) bump
1022    /// [`CACHE_VERSION`] so old cache files are short-circuited at
1023    /// the header check. The companion `cache_version_matches_v8`
1024    /// assertion below fires if a developer regenerates the fixtures
1025    /// without bumping the version constant in the same commit.
1026    ///
1027    /// **If this test fails** and you intend the new encoding to be
1028    /// the contract going forward: regenerate the fixtures by
1029    /// printing `rkyv::to_bytes(&cn)` for each variant, bump
1030    /// `CACHE_VERSION` to `9`, and update both the fixtures and the
1031    /// `cache_version_matches_v8` constant below in the same commit.
1032    ///
1033    /// Gated to little-endian targets — `rkyv::to_bytes` uses native
1034    /// endianness, so the hardcoded bytes are valid for `x86_64` /
1035    /// `aarch64` but would spuriously fail on big-endian platforms
1036    /// (`s390x`, `ppc64be`). `CACHE_VERSION`'s purpose is same-machine
1037    /// read guarding, so non-portable bytes aren't a real defect,
1038    /// just a test-portability footnote.
1039    #[cfg(target_endian = "little")]
1040    #[test]
1041    fn cost_number_archived_bytes_match_v8_fixtures() {
1042        use rust_decimal_macros::dec;
1043        use rustledger_core::{BookedCost, CostNumber};
1044
1045        // Tripwire: regenerating the byte fixtures below without
1046        // bumping CACHE_VERSION leaves users with rotten caches. The
1047        // assertion fires when CACHE_VERSION advances past 8, forcing
1048        // the developer to also update the fixtures (or remove this
1049        // tripwire if v9's contract is identical to v8 for CostNumber
1050        // — which is unusual but possible).
1051        // v9 (#1340), v10 (string escape-decoding), v11 (`MetaValue::Int`), and
1052        // v12 (`CachedOptions` field-parity) all bumped CACHE_VERSION without
1053        // touching the `CostNumber` archived layout these fixtures pin, so the
1054        // byte arrays below are still valid and only FIXTURE_VERSION moves.
1055        // v13 (#1700) ADDS `CostNumber::Compound` at the END of the enum:
1056        // existing discriminants and payload encodings are unchanged (the
1057        // arrays below still pin them), and a fixture for the new variant
1058        // joins them.
1059        const FIXTURE_VERSION: u32 = 14;
1060        assert_eq!(
1061            CACHE_VERSION, FIXTURE_VERSION,
1062            "CACHE_VERSION advanced past the fixture version; regenerate \
1063             the byte fixtures in this test and update FIXTURE_VERSION, \
1064             or remove the tripwire if v{CACHE_VERSION}'s CostNumber \
1065             encoding is byte-identical to the fixtures.",
1066        );
1067
1068        let cases: &[(&str, CostNumber, &[u8])] = &[
1069            (
1070                "PerUnit { value: 150 }",
1071                CostNumber::PerUnit { value: dec!(150) },
1072                &[
1073                    0, 0, 0, 0, 0, 150, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
1074                    0, 0, 0, 0, 0, 0, 0,
1075                ],
1076            ),
1077            (
1078                "Compound { per_unit: 5, total: 10 }",
1079                CostNumber::Compound {
1080                    per_unit: dec!(5),
1081                    total: dec!(10),
1082                },
1083                &[
1084                    3, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 0, 0, 0, 0,
1085                    0, 0, 0, 0, 0, 0, 0,
1086                ],
1087            ),
1088            (
1089                "Total { value: 1500 }",
1090                CostNumber::Total { value: dec!(1500) },
1091                &[
1092                    1, 0, 0, 0, 0, 220, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
1093                    0, 0, 0, 0, 0, 0, 0,
1094                ],
1095            ),
1096            (
1097                "PerUnitFromTotal { per_unit: 150, total: 300 }",
1098                CostNumber::PerUnitFromTotal(BookedCost::new(dec!(150), dec!(300), dec!(2))),
1099                &[
1100                    2, 0, 0, 0, 0, 150, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 44, 1, 0, 0,
1101                    0, 0, 0, 0, 0, 0, 0, 0,
1102                ],
1103            ),
1104        ];
1105        let mut mismatches = Vec::new();
1106        for (name, cn, expected) in cases {
1107            let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(cn).unwrap();
1108            if bytes.as_ref() != *expected {
1109                mismatches.push(format!("  `{name}` → {:?}", bytes.as_ref()));
1110            }
1111        }
1112        assert!(
1113            mismatches.is_empty(),
1114            "rkyv layout drifted from v8 fixtures — bump CACHE_VERSION and \
1115             update the fixtures in this test if intentional. Actual bytes:\n{}",
1116            mismatches.join("\n"),
1117        );
1118    }
1119
1120    /// Layout-hash tripwire for [`rustledger_core::MetaValue`] — generalizes the
1121    /// `CostNumber` frozen-byte fixtures above to the metadata value type the
1122    /// cache also archives.
1123    ///
1124    /// The `CostNumber` fixtures only catch drift in cost numbers. A `MetaValue`
1125    /// variant reorder, or an rkyv encoding shift in how `InternedStr` / `String`
1126    /// / `Decimal` / `Amount` pack, changes the on-disk metadata bytes while
1127    /// `CostNumber` stays byte-identical — and `MetaValue::Int` (v11) was
1128    /// previously guarded only by a code comment, not a test. This hashes the
1129    /// archived bytes of one of every `MetaValue` variant (declaration order,
1130    /// length-prefixed) and pins the digest. Any archived-layout drift trips this,
1131    /// forcing the developer to bump `CACHE_VERSION` (so stale on-disk caches
1132    /// short-circuit at the header check) and regenerate the hash.
1133    ///
1134    /// Little-endian only, like the `CostNumber` fixtures — `rkyv::to_bytes` uses
1135    /// native endianness, and `CACHE_VERSION` guards same-machine reads.
1136    #[cfg(target_endian = "little")]
1137    #[test]
1138    fn meta_value_archived_layout_hash_matches() {
1139        use rustledger_core::{Account, Currency, Link, MetaValue, Tag};
1140
1141        // Tripwire: regenerating the hash without bumping CACHE_VERSION leaves
1142        // users with rotten metadata caches.
1143        // v13 (#1700) added a CostNumber variant; MetaValue's archived
1144        // layout is untouched, so per the tripwire contract only the
1145        // fixture version moves.
1146        const FIXTURE_VERSION: u32 = 14;
1147        const META_VALUE_LAYOUT_HASH: &str =
1148            "43e3c258fe376cede6a6c2c975100bcf67ddda0ab84b21566b123c01e0a54b25";
1149        assert_eq!(
1150            CACHE_VERSION, FIXTURE_VERSION,
1151            "CACHE_VERSION advanced past the MetaValue layout-hash fixture; if the \
1152             MetaValue archived layout changed, bump CACHE_VERSION and regenerate \
1153             META_VALUE_LAYOUT_HASH below in the same commit, else just bump \
1154             FIXTURE_VERSION.",
1155        );
1156
1157        // One value of every variant in declaration order. Each is archived alone
1158        // (no metadata map), so the bytes are deterministic.
1159        let variants: &[MetaValue] = &[
1160            MetaValue::String("USD".to_string()),
1161            MetaValue::Account(Account::from("Assets:Bank")),
1162            MetaValue::Currency(Currency::from("USD")),
1163            MetaValue::Tag(Tag::from("t")),
1164            MetaValue::Link(Link::from("t")),
1165            MetaValue::Date(rustledger_core::naive_date(2024, 1, 15).unwrap()),
1166            MetaValue::Number(dec!(42)),
1167            MetaValue::Bool(true),
1168            MetaValue::Amount(Amount::new(dec!(10), "USD")),
1169            MetaValue::None,
1170            MetaValue::Int(42),
1171        ];
1172
1173        let mut hasher = Hasher::new();
1174        for mv in variants {
1175            let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(mv).unwrap();
1176            // Length-prefix so a byte moving across a variant boundary can't be
1177            // masked by a compensating change in the neighbor.
1178            hasher.update(&(bytes.len() as u64).to_le_bytes());
1179            hasher.update(&bytes);
1180        }
1181        let digest = hasher.finalize().to_hex();
1182
1183        assert_eq!(
1184            digest.as_str(),
1185            META_VALUE_LAYOUT_HASH,
1186            "MetaValue archived layout changed. If intentional, bump CACHE_VERSION \
1187             and set META_VALUE_LAYOUT_HASH to: {digest}",
1188        );
1189    }
1190
1191    #[test]
1192    fn test_reintern_directives_deduplication() {
1193        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1194
1195        // Create multiple transactions with the same account
1196        let mut directives = vec![];
1197        for i in 0..5 {
1198            let txn = Transaction::new(date, format!("Txn {i}"))
1199                .with_synthesized_posting(Posting::new(
1200                    "Expenses:Food",
1201                    Amount::new(dec!(10.00), "USD"),
1202                ))
1203                .with_synthesized_posting(Posting::auto("Assets:Checking"));
1204            directives.push(Spanned::new(Directive::Transaction(txn), Span::new(0, 50)));
1205        }
1206
1207        // Re-intern should deduplicate the repeated account names and currencies
1208        let dedup_count = reintern_directives(&mut directives);
1209
1210        // We should have deduplicated:
1211        // - "Expenses:Food" appears 5 times but only first is new (4 dedup)
1212        // - "USD" appears 5 times but only first is new (4 dedup)
1213        // - "Assets:Checking" appears 5 times but only first is new (4 dedup)
1214        // Total: 12 deduplications
1215        assert_eq!(dedup_count, 12);
1216    }
1217
1218    #[test]
1219    fn test_cached_options_roundtrip() {
1220        let mut opts = Options::new();
1221        opts.title = Some("Test Ledger".to_string());
1222        opts.operating_currency = vec!["USD".to_string(), "EUR".to_string()];
1223        opts.render_commas = true;
1224
1225        let cached = CachedOptions::from(&opts);
1226        let restored: Options = cached.into();
1227
1228        assert_eq!(restored.title, Some("Test Ledger".to_string()));
1229        assert_eq!(restored.operating_currency, vec!["USD", "EUR"]);
1230        assert!(restored.render_commas);
1231    }
1232
1233    /// Structural guard (fitness function): populate EVERY non-transient
1234    /// `Options` field with a non-default value, round-trip through
1235    /// `CachedOptions`, and assert nothing was dropped. A new `Options` field
1236    /// that `CachedOptions` forgets to carry fails here — the bug class that
1237    /// silently dropped `display_precision` / `use_precise_interpolation` /
1238    /// `plugin_processing_mode` (and `set_options` before #1340).
1239    ///
1240    /// `warnings` is intentionally transient (re-derived, not cached), so it is
1241    /// left default on both sides. **When you add a field to `Options`, set it
1242    /// here too.**
1243    #[test]
1244    fn cached_options_field_parity() {
1245        use rust_decimal_macros::dec;
1246
1247        let mut opts = Options::new();
1248        opts.title = Some("T".into());
1249        opts.filename = Some("f.beancount".into());
1250        opts.operating_currency = vec!["USD".into(), "EUR".into()];
1251        opts.name_assets = "A".into();
1252        opts.name_liabilities = "L".into();
1253        opts.name_equity = "Q".into();
1254        opts.name_income = "I".into();
1255        opts.name_expenses = "X".into();
1256        opts.account_rounding = Some("Equity:Round".into());
1257        opts.account_previous_balances = "Opening".into();
1258        opts.account_previous_earnings = "Earn".into();
1259        opts.account_previous_conversions = "Conv".into();
1260        opts.account_current_earnings = "CurEarn".into();
1261        opts.account_current_conversions = Some("CurConv".into());
1262        opts.account_unrealized_gains = Some("Unreal".into());
1263        opts.conversion_currency = Some("NOTHING".into());
1264        opts.inferred_tolerance_default =
1265            std::iter::once(("USD".to_string(), dec!(0.005))).collect();
1266        opts.inferred_tolerance_multiplier = dec!(1.5);
1267        opts.infer_tolerance_from_cost = true;
1268        opts.use_legacy_fixed_tolerances = true;
1269        opts.experiment_explicit_tolerances = true;
1270        opts.use_precise_interpolation = true;
1271        opts.booking_method = "FIFO".into();
1272        opts.render_commas = true;
1273        opts.display_precision = [("USD".to_string(), 4u32), ("JPY".to_string(), 0)]
1274            .into_iter()
1275            .collect();
1276        opts.allow_pipe_separator = true;
1277        opts.long_string_maxlines = 99;
1278        opts.documents = vec!["docs".into()];
1279        opts.plugin_processing_mode = "raw".into();
1280        opts.custom = std::iter::once(("k".to_string(), "v".to_string())).collect();
1281        opts.set_options = std::iter::once("booking_method".to_string()).collect();
1282        // `warnings` left default (transient — not cached).
1283
1284        let restored: Options = CachedOptions::from(&opts).into();
1285        assert_eq!(
1286            restored, opts,
1287            "a CachedOptions field was dropped on the cache round-trip"
1288        );
1289    }
1290
1291    /// Regression for #1340: `set_options` must survive the cache
1292    /// round-trip. It gates `resolve_effective_booking_method`, so
1293    /// dropping it makes a cache hit re-book FIFO/LIFO ledgers as
1294    /// STRICT (the file-level `option "booking_method"` is ignored).
1295    #[test]
1296    fn test_cached_options_preserves_set_options_for_booking_method() {
1297        let mut opts = Options::new();
1298        // `set()` is what a parsed `option "booking_method" "FIFO"`
1299        // calls — it records both the value AND the set-membership.
1300        opts.set("booking_method", "FIFO");
1301        assert!(opts.set_options.contains("booking_method"));
1302
1303        let cached = CachedOptions::from(&opts);
1304        let restored: Options = cached.into();
1305
1306        assert_eq!(restored.booking_method, "FIFO");
1307        assert!(
1308            restored.set_options.contains("booking_method"),
1309            "set_options dropped across cache round-trip — booking method \
1310             resolution would fall back to the STRICT default on a cache hit"
1311        );
1312    }
1313
1314    #[test]
1315    fn test_cache_entry_file_paths() {
1316        let entry = CacheEntry {
1317            directives: vec![],
1318            options: CachedOptions::from(&Options::new()),
1319            plugins: vec![],
1320            files: vec![
1321                "/path/to/ledger.beancount".to_string(),
1322                "/path/to/include.beancount".to_string(),
1323            ],
1324        };
1325
1326        let paths = entry.file_paths();
1327        assert_eq!(paths.len(), 2);
1328        assert_eq!(paths[0], PathBuf::from("/path/to/ledger.beancount"));
1329        assert_eq!(paths[1], PathBuf::from("/path/to/include.beancount"));
1330    }
1331
1332    #[test]
1333    fn test_reintern_balance_directive() {
1334        use rustledger_core::Balance;
1335
1336        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1337        let balance = Balance::new(date, "Assets:Checking", Amount::new(dec!(1000.00), "USD"));
1338
1339        let mut directives = vec![
1340            Spanned::new(Directive::Balance(balance.clone()), Span::new(0, 50)),
1341            Spanned::new(Directive::Balance(balance), Span::new(51, 100)),
1342        ];
1343
1344        let dedup_count = reintern_directives(&mut directives);
1345        // Second occurrence of "Assets:Checking" and "USD" should be deduplicated
1346        assert_eq!(dedup_count, 2);
1347    }
1348
1349    #[test]
1350    fn test_reintern_open_close_directives() {
1351        use rustledger_core::{Close, Open};
1352
1353        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1354        let open = Open::new(date, "Assets:Checking");
1355        let close = Close::new(date, "Assets:Checking");
1356
1357        let mut directives = vec![
1358            Spanned::new(Directive::Open(open), Span::new(0, 50)),
1359            Spanned::new(Directive::Close(close), Span::new(51, 100)),
1360        ];
1361
1362        let dedup_count = reintern_directives(&mut directives);
1363        // Second "Assets:Checking" should be deduplicated
1364        assert_eq!(dedup_count, 1);
1365    }
1366}