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