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