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            //
250            // Structured exactly as `DiskFileSystem::read` rather than as
251            // `from_utf8_lossy(&bytes).into_owned()`, which is the same
252            // operation by a much slower route: it validates through
253            // `Utf8Chunks` a byte at a time instead of the word-at-a-time
254            // check `String::from_utf8` runs, and then copies the whole file
255            // even when every byte was already valid. `String::from_utf8`
256            // takes the `Vec` by value and keeps the allocation. Only a
257            // genuinely non-UTF-8 file pays for the lossy rebuild, and that
258            // branch produces the identical string.
259            if let Ok(bytes) = fs::read(&path) {
260                let content = match String::from_utf8(bytes) {
261                    Ok(s) => s,
262                    Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
263                };
264                source_map.add_file(path, content.into());
265            }
266        }
267
268        let plugins: Vec<crate::Plugin> = self
269            .plugins
270            .iter()
271            .map(|p| crate::Plugin {
272                name: p.name.clone(),
273                config: p.config.clone(),
274                span: rustledger_parser::Span::ZERO,
275                file_id: 0,
276                force_python: p.force_python,
277            })
278            .collect();
279
280        let options: Options = self.options.into();
281        let display_context = crate::build_display_context(&self.directives, &options);
282
283        crate::LoadResult {
284            directives: self.directives,
285            options,
286            plugins,
287            source_map,
288            errors: Vec::new(),
289            display_context,
290        }
291    }
292}
293
294/// Magic bytes to identify cache files.
295const CACHE_MAGIC: &[u8; 8] = b"RLEDGER\0";
296
297/// Cache version - increment when format changes.
298///
299/// v1: Initial release with string-based Decimal/NaiveDate
300/// v2: Binary Decimal (16 bytes) and `NaiveDate` (i32 days)
301/// v3: Fixed account type defaults in `CachedOptions`
302/// v4: Hash algorithm switched from SHA-256 to BLAKE3 — same 32-byte
303///     output so the header layout is unchanged, but old hashes won't
304///     match new files. Bumping the version short-circuits stale
305///     caches at the header check instead of paying the rkyv
306///     deserialize cost only to fail the hash compare.
307/// v5: `Transaction.postings: Vec<Posting>` became
308///     `Vec<Spanned<Posting>>` (#1151). The inner posting bytes
309///     gained a `Span + file_id` per entry, so old cache files
310///     would rkyv-deserialize into the new type as junk. Header
311///     check forces a rebuild instead.
312/// v6: The #1163 newtype slices (#1169 `Currency`, #1171 `Account`,
313///     #1172 `Tag`, #1173 `Link`, #1174 `MetaValue`) swapped variant
314///     payload types from `InternedStr`/`String` to typed newtypes.
315///     The archived layout coincidentally matches `AsInternedStr`
316///     in most cases, but `MetaValue::{Account,Currency,Tag,Link}`
317///     and `Transaction.tags`/`links` (plus the parallel `Document`
318///     fields) changed their archive wrappers. Bumping the version
319///     forces regeneration so we don't risk rkyv reading old bytes
320///     into a structurally-different `ArchivedMetaValue`.
321/// v7: `PriceAnnotation` refactored from 6-variant enum to
322///     `{ kind: PriceKind, amount: Option<IncompleteAmount> }`
323///     (#1167). Old cache bytes for the enum's discriminant would
324///     deserialize as nonsense in the new struct layout.
325/// v8: `CostSpec.{number_per,number_total}: Option<Decimal>` collapsed
326///     into `CostSpec.number: Option<CostNumber>` where `CostNumber` is
327///     a 3-variant enum (`PerUnit`, `Total`, `PerUnitFromTotal`)
328///     (#1164). The archived layout is structurally different
329///     (`Option<Decimal>` + `Option<Decimal>` → `Option<discriminant +
330///     payload>`); reading v7 bytes into the v8 layout would produce
331///     garbage cost numbers. Bumping forces regeneration.
332///     Subsequent #1164 follow-up commits converted `CostNumber`'s
333///     variants from tuple form (`PerUnit(Decimal)`) to struct form
334///     (`PerUnit { value: Decimal }`) so serde could apply
335///     `tag = "kind"` for cross-boundary wire unification. The rkyv-
336///     archived layout for a single-field struct variant is byte-
337///     identical to the tuple variant (both pack `Archived<Decimal>`
338///     positionally) — verified against rkyv 0.8.16 — so this change
339///     does NOT require a separate version bump. If a future rkyv
340///     version changes that encoding, OR if `CostNumber` gains
341///     additional fields, bump `CACHE_VERSION` to the next value.
342/// v13: `CostNumber` gained the `Compound { per_unit, total }` variant
343///     (#1700) and the parser now emits it for `{a # b}` cost specs —
344///     exactly the "gains additional fields" case the v12 note calls
345///     out. Without the bump, a cache written by a pre-#1700 binary
346///     serves the old misparse (`Total{b}` / `PerUnit{b}`) to fixed
347///     binaries, resurrecting the bug for any previously-loaded ledger.
348/// v14: green compound-cost conversion now retries past unparsable
349///     pre-/post-hash NUMBER tokens like red (#1713); inputs with garbage
350///     around `{a # b}` parse to different `CostNumber` values than v13
351///     cached them as.
352/// v15: EOF now terminates a line in the parser's error-recovery walkers, so a
353///     malformed FINAL line without a trailing newline emits its diagnostic
354///     (#1884). Previously such a file parsed to zero errors and `rledger
355///     check` exited 0 on it. The cached `errors` differ, and a cache written
356///     by a pre-fix binary would serve the silent-pass result to a fixed one —
357///     resurrecting exactly the bug for every ledger already in the cache,
358///     which is the worst case since the symptom is "no error reported".
359/// v16: a sign separated from its operand is no longer dropped. `- 7.50 USD`
360///     (valid beancount) parsed as **+7.50**, and `-,123.00 USD` (malformed)
361///     parsed as **+123.00** with no diagnostic, because the sign landed
362///     outside the `AMOUNT` node where nothing read it. Cached postings from a
363///     pre-fix binary therefore hold the WRONG SIGN, and the malformed case
364///     also holds an empty `errors` list. Serving either to a fixed binary
365///     reproduces the original bug on every ledger already in the cache — and
366///     a flipped sign is silent, so nothing downstream would flag it.
367/// v17: `balance` / `price` values are no longer read as "the first NUMBER
368///     token". Parenthesized arithmetic evaluated to the leading operand
369///     (`(1 + 5) / 2.1 USD` asserted against **1**, not 2.857…), and a split
370///     numeral did the same (`1,23,4.50 USD` -> **1**, a thousandfold error).
371///     Cached directives from a pre-fix binary therefore hold those wrong
372///     VALUES, and the malformed cases also hold an empty `errors` list — both
373///     silent, so nothing downstream would flag them.
374/// v9: `CachedOptions` gained a `set_options: Vec<String>` field
375///     (#1340). It was previously dropped, so a cache hit lost the
376///     record of which options the file explicitly set — making
377///     `resolve_effective_booking_method` re-book FIFO/LIFO ledgers as
378///     STRICT. The new trailing field changes the archived layout, so
379///     old bytes must be regenerated.
380/// v10: String literals are now escape-decoded at parse (`\"`->`"`, etc.);
381///     the stored narration/payee/meta/etc. bytes differ from the old raw
382///     form, so a cache hit would serve stale, still-escaped strings.
383/// v11: `MetaValue` gained an `Int(i64)` variant (appended last). Integer
384///     metadata literals (`key: 42`) now archive as `Int` rather than
385///     `Number`, and the new discriminant changes the enum's archived
386///     layout, so old bytes must be regenerated.
387/// v12: `CachedOptions` gained `display_precision`, `use_precise_interpolation`,
388///     and `plugin_processing_mode` — previously dropped, so a cache hit
389///     silently ignored `option "display_precision" "USD:0.0001"` (formatting
390///     fell back to inferred precision) and the other two settings. New fields
391///     change the archived layout, so old bytes must be regenerated.
392/// v18: arithmetic in a COST SPEC is now evaluated rather than truncated to
393///     its first operand (#1939). `{10.00 * 3 USD}` previously archived a
394///     cost of `10.00`; it now archives `30.00`. The layout is unchanged, so
395///     nothing here would REJECT the old bytes — which is exactly why the
396///     bump is required: a stale cache would keep serving the truncated cost
397///     basis, and the file would keep failing to balance, on a build that has
398///     the fix. Verified by hitting precisely that during development.
399/// v19: arithmetic is now evaluated in METADATA values and BALANCE
400///     TOLERANCES as well as cost specs (#1944). `key: 2 * 3` archives 6 not
401///     2, and `~ 0.005 * 2` archives 0.010 not 0.005. Values again, not
402///     layout — and again the reason the bump is mandatory: a stale cache
403///     would keep serving the truncated tolerance and keep rejecting a file
404///     the fixed build accepts.
405/// v20: account names accept any NON-ASCII character inside a component
406///     (#1930). A ledger that previously failed to parse now yields
407///     directives, and one that parsed may gain account names it did not
408///     have. Layout unchanged, so old bytes would be accepted and a cached
409///     PARSE FAILURE served on a build that can read the file.
410/// v21: a `#tag` / `^link` on a directive that does not take one is now a
411///     parse error (#1949). A file that previously loaded clean can now carry
412///     errors, so a stale cache would serve the old clean parse on a build
413///     that objects.
414/// v22: a metadata key now needs at least two characters, as in beancount
415///     (#1955). A file using `k: 42` previously loaded clean and now carries a
416///     parse error, so a stale cache would serve the old clean parse on a
417///     build that objects.
418/// v23: a `^link` is no longer accepted as a metadata VALUE (#1954), so a
419///     file using `ref: ^x` moves from clean to erroring. A stale cache would
420///     serve the old clean parse.
421/// v24: tags and links are no longer accepted as `custom` / `pushmeta`
422///     values (#1958), so a file using them moves from clean to erroring and
423///     a stale cache would serve the old clean parse.
424/// v25: transaction headers beancount's grammar rejects are now parse errors
425///     (#2008) - a third header string, a string after a tag/link, or junk
426///     after the narration. Same reasoning as v24: those files move from
427///     clean to erroring, and this was observed for real - the first
428///     `rledger check` run against the fixtures after the change reported
429///     only the old downstream `E1001`s, because the cache still held the
430///     permissive parse.
431/// v26: malformed cost-spec component lists are now parse errors (#2008 cases
432///     1 and 2) - an empty comma-delimited component, or a token after a
433///     component is already complete. Same clean-to-erroring move as v25, and
434///     a separate version because a cache written between the two lands is
435///     stale for this change even though it carries v25.
436/// v27: a cost spec with no number the author wrote now archives `number:
437///     None` instead of an invented zero (#2008). `{ # CCY}` used to become
438///     `Compound { per_unit: 0, total: 0 }`, and a malformed spec had a number
439///     scraped out of it. Both changed the ARCHIVED `CostSpec`, so a stale cache
440///     would serve the invented number to a build that no longer produces one.
441///
442/// v28: a literal `-0.00` now parses to an UNSIGNED zero (matching
443///     beancount, whose parser yields `Decimal('0.00')`), where the green
444///     path's bare `-n` previously archived a signed zero. That is parser
445///     OUTPUT, so a stale cache would keep serving `-0.00` from a build that
446///     no longer produces one.
447///
448/// v29: `Posting::cost` and `Posting::price` are boxed. rkyv mirrors the
449///     in-memory layout, so `ArchivedPosting` changed shape — a v28 file read
450///     as v29 would interpret an inline `CostSpec` as a relative pointer.
451///     This is a layout change rather than a content change, so unlike the
452///     entries above nothing about the ledger's meaning moved.
453///
454/// Public so `rustledger-wasm` can pin its own cache version against this one.
455/// Both caches archive the same `Vec<Directive>`, so a parser change that
456/// alters PARSER OUTPUT has to bump both — and on #1942 only this one was
457/// bumped, which review caught rather than any test. See
458/// `loader_cache_version_is_pinned` in `rustledger-wasm/src/cache.rs`.
459pub const CACHE_VERSION: u32 = 29;
460
461/// Cache header stored at the start of cache files.
462#[derive(Debug, Clone)]
463struct CacheHeader {
464    /// Magic bytes for identification.
465    magic: [u8; 8],
466    /// Cache format version.
467    version: u32,
468    /// BLAKE3 hash of source files (path + mtime + size).
469    hash: [u8; 32],
470    /// Length of the serialized data.
471    data_len: u64,
472}
473
474impl CacheHeader {
475    const SIZE: usize = 8 + 4 + 32 + 8;
476
477    fn to_bytes(&self) -> [u8; Self::SIZE] {
478        let mut buf = [0u8; Self::SIZE];
479        buf[0..8].copy_from_slice(&self.magic);
480        buf[8..12].copy_from_slice(&self.version.to_le_bytes());
481        buf[12..44].copy_from_slice(&self.hash);
482        buf[44..52].copy_from_slice(&self.data_len.to_le_bytes());
483        buf
484    }
485
486    fn from_bytes(bytes: &[u8]) -> Option<Self> {
487        if bytes.len() < Self::SIZE {
488            return None;
489        }
490
491        let mut magic = [0u8; 8];
492        magic.copy_from_slice(&bytes[0..8]);
493
494        let version = u32::from_le_bytes(bytes[8..12].try_into().ok()?);
495
496        let mut hash = [0u8; 32];
497        hash.copy_from_slice(&bytes[12..44]);
498
499        let data_len = u64::from_le_bytes(bytes[44..52].try_into().ok()?);
500
501        Some(Self {
502            magic,
503            version,
504            hash,
505            data_len,
506        })
507    }
508}
509
510/// Compute a hash of the given files and their modification times.
511///
512/// Files whose metadata cannot be read (e.g., deleted between load and cache)
513/// contribute only their path to the hash. This is intentional — the resulting
514/// hash mismatch will cause a cache miss on next load.
515fn compute_hash(files: &[&Path]) -> [u8; 32] {
516    let mut hasher = Hasher::new();
517
518    for file in files {
519        // Hash the file path
520        hasher.update(file.to_string_lossy().as_bytes());
521
522        // Hash the modification time (skip silently if inaccessible)
523        if let Ok(metadata) = fs::metadata(file) {
524            if let Ok(mtime) = metadata.modified()
525                && let Ok(duration) = mtime.duration_since(std::time::UNIX_EPOCH)
526            {
527                hasher.update(&duration.as_secs().to_le_bytes());
528                hasher.update(&duration.subsec_nanos().to_le_bytes());
529            }
530            // Hash the file size
531            hasher.update(&metadata.len().to_le_bytes());
532        }
533    }
534
535    *hasher.finalize().as_bytes()
536}
537
538/// Environment variable that overrides the default cache filename pattern.
539///
540/// The value is a path that may contain `{filename}` as a placeholder for the
541/// source file's basename. Relative paths are resolved against the source
542/// file's directory; absolute paths are used as-is. Mirrors Python beancount's
543/// `BEANCOUNT_LOAD_CACHE_FILENAME`.
544pub const CACHE_FILENAME_ENV: &str = "BEANCOUNT_LOAD_CACHE_FILENAME";
545
546/// Environment variable that disables the binary cache entirely when set.
547///
548/// Mirrors Python beancount's `BEANCOUNT_DISABLE_LOAD_CACHE`.
549pub const DISABLE_CACHE_ENV: &str = "BEANCOUNT_DISABLE_LOAD_CACHE";
550
551/// Returns the cache file path for a given source file.
552///
553/// Resolution order:
554/// 1. If `BEANCOUNT_LOAD_CACHE_FILENAME` is set, substitute `{filename}` with
555///    the source basename and resolve relative paths against the source dir.
556/// 2. Otherwise, default to a hidden dotfile alongside the source via
557///    [`default_cache_path`]: `path/to/main.beancount` →
558///    `path/to/.main.beancount.cache`.
559///
560/// The dotfile prefix matches Python beancount's `.{filename}.picklecache`
561/// convention, so the cache stays out of the way of `ls` and most file
562/// explorers without breaking from the established beancount ecosystem
563/// behavior. See issue #939.
564///
565/// This function reads process env. Tests that need a deterministic path
566/// regardless of the caller's environment should use [`default_cache_path`]
567/// directly.
568pub fn cache_path(source: &Path) -> PathBuf {
569    if let Ok(pattern) = std::env::var(CACHE_FILENAME_ENV)
570        && !pattern.is_empty()
571    {
572        return resolve_cache_pattern(source, &pattern);
573    }
574    default_cache_path(source)
575}
576
577/// Returns the default cache file path (no env-var lookup).
578///
579/// Use this when you need a path that is independent of process env, e.g.
580/// in tests that mustn't be perturbed by a developer's
581/// `BEANCOUNT_LOAD_CACHE_FILENAME`.
582#[must_use]
583pub fn default_cache_path(source: &Path) -> PathBuf {
584    let mut path = source.to_path_buf();
585    let name = path.file_name().map_or_else(
586        || ".ledger.cache".to_string(),
587        |n| format!(".{}.cache", n.to_string_lossy()),
588    );
589    path.set_file_name(name);
590    path
591}
592
593/// Resolve a `BEANCOUNT_LOAD_CACHE_FILENAME` pattern against a source path.
594///
595/// The `"{filename}"` token below is a literal user-facing substitution
596/// placeholder (matching Python beancount), not a `format!` argument — hence
597/// the explicit allow.
598#[allow(clippy::literal_string_with_formatting_args)]
599fn resolve_cache_pattern(source: &Path, pattern: &str) -> PathBuf {
600    let filename = source.file_name().map_or_else(
601        || "ledger".to_string(),
602        |n| n.to_string_lossy().into_owned(),
603    );
604    let resolved = pattern.replace("{filename}", &filename);
605    let p = PathBuf::from(&resolved);
606    if p.is_absolute() {
607        return p;
608    }
609    source.parent().map_or(p.clone(), |parent| parent.join(&p))
610}
611
612/// Returns the legacy (pre-#939) cache path: `<source>.cache` alongside source.
613///
614/// Used by `save_cache_entry` to opportunistically clean up stale cache files
615/// from earlier rustledger versions. Not part of the lookup path.
616fn legacy_cache_path(source: &Path) -> PathBuf {
617    let mut path = source.to_path_buf();
618    let name = path.file_name().map_or_else(
619        || "ledger.cache".to_string(),
620        |n| format!("{}.cache", n.to_string_lossy()),
621    );
622    path.set_file_name(name);
623    path
624}
625
626/// Returns true if `BEANCOUNT_DISABLE_LOAD_CACHE` is set in the environment.
627///
628/// Mere presence disables — value is ignored, including empty string. Matches
629/// Python beancount's `os.getenv("BEANCOUNT_DISABLE_LOAD_CACHE") is None`
630/// check.
631#[must_use]
632pub fn cache_disabled_by_env() -> bool {
633    std::env::var_os(DISABLE_CACHE_ENV).is_some()
634}
635
636/// Try to load a cache entry from disk.
637///
638/// Returns `Some(CacheEntry)` if cache is valid and file hashes match,
639/// `None` if cache is missing, invalid, outdated, or
640/// `BEANCOUNT_DISABLE_LOAD_CACHE` is set.
641pub fn load_cache_entry(main_file: &Path) -> Option<CacheEntry> {
642    if cache_disabled_by_env() {
643        return None;
644    }
645    let cache_file = cache_path(main_file);
646    let mut file = fs::File::open(&cache_file).ok()?;
647
648    // Read header
649    let mut header_bytes = [0u8; CacheHeader::SIZE];
650    file.read_exact(&mut header_bytes).ok()?;
651    let header = CacheHeader::from_bytes(&header_bytes)?;
652
653    // Validate magic and version
654    if header.magic != *CACHE_MAGIC {
655        return None;
656    }
657    if header.version != CACHE_VERSION {
658        return None;
659    }
660
661    // Read data
662    let mut data = vec![0u8; header.data_len as usize];
663    file.read_exact(&mut data).ok()?;
664
665    // Deserialize
666    // Intern while deserializing rather than deduplicating afterwards.
667    // rkyv's deserializer carries no interner, so `AsInternedStr` handed
668    // every occurrence its own `Arc<str>` — 40,015 of them on a
669    // 10,000-transaction ledger holding a few dozen distinct strings — and
670    // the caller then walked every directive again through
671    // `reintern_directives` to collapse them. The scope establishes the same
672    // postcondition (equal strings share a pointer) on the way in, so the
673    // second walk is redundant on this path; see `load_result_cached`.
674    //
675    // The guard drops at the end of this function, including on the `?`
676    // paths below, so nothing outlives the load.
677    let entry: CacheEntry = {
678        let _intern = rustledger_core::intern::InternScope::new();
679        rkyv::from_bytes::<CacheEntry, rkyv::rancor::Error>(&data).ok()?
680    };
681
682    // Validate hash against the files stored in the cache
683    let file_paths = entry.file_paths();
684    let file_refs: Vec<&Path> = file_paths.iter().map(PathBuf::as_path).collect();
685    let expected_hash = compute_hash(&file_refs);
686    if header.hash != expected_hash {
687        return None;
688    }
689
690    Some(entry)
691}
692
693/// Save a cache entry to disk.
694///
695/// No-op (returns Ok) when `BEANCOUNT_DISABLE_LOAD_CACHE` is set.
696pub fn save_cache_entry(main_file: &Path, entry: &CacheEntry) -> Result<(), std::io::Error> {
697    if cache_disabled_by_env() {
698        return Ok(());
699    }
700    let cache_file = cache_path(main_file);
701
702    // Compute hash from the files in the entry
703    let file_paths = entry.file_paths();
704    let file_refs: Vec<&Path> = file_paths.iter().map(PathBuf::as_path).collect();
705    let hash = compute_hash(&file_refs);
706
707    // Serialize
708    let data = rkyv::to_bytes::<rkyv::rancor::Error>(entry)
709        .map(|v| v.to_vec())
710        .map_err(|e| std::io::Error::other(e.to_string()))?;
711
712    // Write header + data
713    let header = CacheHeader {
714        magic: *CACHE_MAGIC,
715        version: CACHE_VERSION,
716        hash,
717        data_len: data.len() as u64,
718    };
719
720    // Custom BEANCOUNT_LOAD_CACHE_FILENAME patterns can point at a directory
721    // that doesn't exist yet (e.g. ~/.cache/rledger/foo.cache on a fresh
722    // install). Create the parent eagerly so caching isn't silently disabled.
723    if let Some(parent) = cache_file.parent()
724        && !parent.as_os_str().is_empty()
725    {
726        fs::create_dir_all(parent)?;
727    }
728
729    let mut file = fs::File::create(&cache_file)?;
730    file.write_all(&header.to_bytes())?;
731    file.write_all(&data)?;
732
733    // One-shot cleanup of pre-#939 visible cache files. Only attempt when the
734    // legacy path differs from the new path (i.e., we're not using a custom
735    // pattern that happens to land on the old name) and silently ignore
736    // failures — leaving the file is harmless, just untidy.
737    let legacy = legacy_cache_path(main_file);
738    if legacy != cache_file && legacy.exists() {
739        let _ = fs::remove_file(&legacy);
740    }
741
742    Ok(())
743}
744
745/// Serialize directives to bytes using rkyv (for benchmarking).
746#[cfg(test)]
747fn serialize_directives(directives: &Vec<Spanned<Directive>>) -> Result<Vec<u8>, std::io::Error> {
748    rkyv::to_bytes::<rkyv::rancor::Error>(directives)
749        .map(|v| v.to_vec())
750        .map_err(|e| std::io::Error::other(e.to_string()))
751}
752
753/// Deserialize directives from bytes using rkyv (for benchmarking).
754#[cfg(test)]
755fn deserialize_directives(data: &[u8]) -> Option<Vec<Spanned<Directive>>> {
756    rkyv::from_bytes::<Vec<Spanned<Directive>>, rkyv::rancor::Error>(data).ok()
757}
758
759/// Invalidate the cache for a file.
760///
761/// Removes both the current cache file and any legacy pre-#939
762/// `<file>.cache` sidecar so a subsequent load can't pick up stale data.
763pub fn invalidate_cache(main_file: &Path) {
764    let cache_file = cache_path(main_file);
765    let _ = fs::remove_file(&cache_file);
766
767    let legacy = legacy_cache_path(main_file);
768    if legacy != cache_file {
769        let _ = fs::remove_file(&legacy);
770    }
771}
772
773#[cfg(test)]
774mod tests {
775    use super::*;
776    use crate::dedup::reintern_directives;
777    use rust_decimal_macros::dec;
778    use rustledger_core::{Amount, IncompleteAmount, Posting, Transaction};
779    use rustledger_parser::Span;
780
781    #[test]
782    fn test_cache_header_roundtrip() {
783        let header = CacheHeader {
784            magic: *CACHE_MAGIC,
785            version: CACHE_VERSION,
786            hash: [42u8; 32],
787            data_len: 12345,
788        };
789
790        let bytes = header.to_bytes();
791        let parsed = CacheHeader::from_bytes(&bytes).unwrap();
792
793        assert_eq!(parsed.magic, header.magic);
794        assert_eq!(parsed.version, header.version);
795        assert_eq!(parsed.hash, header.hash);
796        assert_eq!(parsed.data_len, header.data_len);
797    }
798
799    #[test]
800    fn test_compute_hash_deterministic() {
801        let files: Vec<&Path> = vec![];
802        let hash1 = compute_hash(&files);
803        let hash2 = compute_hash(&files);
804        assert_eq!(hash1, hash2);
805    }
806
807    #[test]
808    fn test_serialize_deserialize_roundtrip() {
809        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
810
811        let txn = Transaction::new(date, "Test transaction")
812            .with_payee("Test Payee")
813            .with_synthesized_posting(Posting::new(
814                "Expenses:Test",
815                Amount::new(dec!(100.00), "USD"),
816            ))
817            .with_synthesized_posting(Posting::auto("Assets:Checking"));
818
819        let directives = vec![Spanned::new(Directive::Transaction(txn), Span::new(0, 100))];
820
821        // Serialize
822        let serialized = serialize_directives(&directives).expect("serialization failed");
823
824        // Deserialize
825        let deserialized = deserialize_directives(&serialized).expect("deserialization failed");
826
827        // Verify roundtrip
828        assert_eq!(directives.len(), deserialized.len());
829        let orig_txn = directives[0].value.as_transaction().unwrap();
830        let deser_txn = deserialized[0].value.as_transaction().unwrap();
831
832        assert_eq!(orig_txn.date, deser_txn.date);
833        assert_eq!(orig_txn.payee, deser_txn.payee);
834        assert_eq!(orig_txn.narration, deser_txn.narration);
835        assert_eq!(orig_txn.postings.len(), deser_txn.postings.len());
836
837        // Check first posting
838        assert_eq!(orig_txn.postings[0].account, deser_txn.postings[0].account);
839        assert_eq!(orig_txn.postings[0].units, deser_txn.postings[0].units);
840    }
841
842    #[test]
843    #[ignore = "manual benchmark - run with: cargo test -p rustledger-loader --release -- --ignored --nocapture"]
844    fn bench_cache_performance() {
845        // Generate test directives
846        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
847        let mut directives = Vec::with_capacity(10000);
848
849        for i in 0..10000 {
850            let txn = Transaction::new(date, format!("Transaction {i}"))
851                .with_payee("Store")
852                .with_synthesized_posting(Posting::new(
853                    "Expenses:Food",
854                    Amount::new(dec!(25.00), "USD"),
855                ))
856                .with_synthesized_posting(Posting::auto("Assets:Checking"));
857
858            directives.push(Spanned::new(Directive::Transaction(txn), Span::new(0, 100)));
859        }
860
861        println!("\n=== Cache Benchmark (10,000 directives) ===");
862
863        // Benchmark serialization
864        let start = std::time::Instant::now();
865        let serialized = serialize_directives(&directives).unwrap();
866        let serialize_time = start.elapsed();
867        println!(
868            "Serialize: {:?} ({:.2} MB)",
869            serialize_time,
870            serialized.len() as f64 / 1_000_000.0
871        );
872
873        // Benchmark deserialization
874        let start = std::time::Instant::now();
875        let deserialized = deserialize_directives(&serialized).unwrap();
876        let deserialize_time = start.elapsed();
877        println!("Deserialize: {deserialize_time:?}");
878
879        assert_eq!(directives.len(), deserialized.len());
880
881        println!(
882            "\nSpeedup potential: If parsing takes 100ms, cache load would be {:.1}x faster",
883            100.0 / deserialize_time.as_millis() as f64
884        );
885    }
886
887    // Note: end-to-end coverage of `cache_path()` (including the
888    // `BEANCOUNT_LOAD_CACHE_FILENAME` env var) lives in
889    // `tests/cache_env_var_test.rs`, which can mutate process env without
890    // tripping the crate's `forbid(unsafe_code)`. The tests below cover the
891    // pure pattern-resolution logic and the legacy-path helper.
892
893    /// Fail fast if a developer has set the cache env vars locally — the
894    /// roundtrip tests in this module call `save_cache_entry`/`invalidate_cache`
895    /// which read process env, and a custom pattern would silently redirect
896    /// writes elsewhere (or fail in surprising ways). CI runs with a clean env.
897    fn assert_clean_cache_env() {
898        for var in [CACHE_FILENAME_ENV, DISABLE_CACHE_ENV] {
899            assert!(
900                std::env::var_os(var).is_none(),
901                "unset {var} before running this test"
902            );
903        }
904    }
905
906    #[test]
907    fn test_resolve_cache_pattern_relative_with_substitution() {
908        let source = Path::new("/home/user/finances/main.beancount");
909        let resolved = resolve_cache_pattern(source, ".cache/{filename}.bin");
910        assert_eq!(
911            resolved,
912            Path::new("/home/user/finances/.cache/main.beancount.bin")
913        );
914    }
915
916    #[test]
917    fn test_resolve_cache_pattern_absolute() {
918        let source = Path::new("/home/user/main.beancount");
919        let resolved = resolve_cache_pattern(source, "/var/cache/rledger/{filename}.cache");
920        assert_eq!(
921            resolved,
922            Path::new("/var/cache/rledger/main.beancount.cache")
923        );
924    }
925
926    #[test]
927    fn test_resolve_cache_pattern_no_substitution() {
928        // Pattern without {filename} is used verbatim.
929        let source = Path::new("/home/user/main.beancount");
930        let resolved = resolve_cache_pattern(source, "fixed.cache");
931        assert_eq!(resolved, Path::new("/home/user/fixed.cache"));
932    }
933
934    #[test]
935    fn test_legacy_cache_path() {
936        let source = Path::new("/tmp/ledger.beancount");
937        assert_eq!(
938            legacy_cache_path(source),
939            Path::new("/tmp/ledger.beancount.cache")
940        );
941    }
942
943    #[test]
944    fn test_save_load_cache_entry_roundtrip() {
945        use std::io::Write;
946
947        assert_clean_cache_env();
948
949        // Create a temp directory
950        let temp_dir = std::env::temp_dir().join("rustledger_cache_test");
951        let _ = fs::create_dir_all(&temp_dir);
952
953        // Create a temp beancount file
954        let beancount_file = temp_dir.join("test.beancount");
955        let mut f = fs::File::create(&beancount_file).unwrap();
956        writeln!(f, "2024-01-01 open Assets:Test").unwrap();
957        drop(f);
958
959        // Create a cache entry
960        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
961        let txn =
962            Transaction::new(date, "Test").with_synthesized_posting(Posting::auto("Assets:Test"));
963        let directives = vec![Spanned::new(Directive::Transaction(txn), Span::new(0, 50))];
964
965        let entry = CacheEntry {
966            directives,
967            options: CachedOptions::from(&Options::new()),
968            plugins: vec![CachedPlugin {
969                name: "test_plugin".to_string(),
970                config: Some("config".to_string()),
971                force_python: false,
972            }],
973            files: vec![beancount_file.to_string_lossy().to_string()],
974        };
975
976        // Save cache
977        save_cache_entry(&beancount_file, &entry).expect("save failed");
978
979        // Load cache
980        let loaded = load_cache_entry(&beancount_file).expect("load failed");
981
982        // Verify
983        assert_eq!(loaded.directives.len(), entry.directives.len());
984        assert_eq!(loaded.plugins.len(), 1);
985        assert_eq!(loaded.plugins[0].name, "test_plugin");
986        assert_eq!(loaded.plugins[0].config, Some("config".to_string()));
987        assert_eq!(loaded.files.len(), 1);
988
989        // Cleanup
990        let _ = fs::remove_file(&beancount_file);
991        let _ = fs::remove_file(cache_path(&beancount_file));
992        let _ = fs::remove_dir(&temp_dir);
993    }
994
995    #[test]
996    fn test_invalidate_cache() {
997        use std::io::Write;
998
999        assert_clean_cache_env();
1000
1001        let temp_dir = std::env::temp_dir().join("rustledger_invalidate_test");
1002        let _ = fs::create_dir_all(&temp_dir);
1003
1004        let beancount_file = temp_dir.join("test.beancount");
1005        let mut f = fs::File::create(&beancount_file).unwrap();
1006        writeln!(f, "2024-01-01 open Assets:Test").unwrap();
1007        drop(f);
1008
1009        // Create and save a cache
1010        let entry = CacheEntry {
1011            directives: vec![],
1012            options: CachedOptions::from(&Options::new()),
1013            plugins: vec![],
1014            files: vec![beancount_file.to_string_lossy().to_string()],
1015        };
1016        save_cache_entry(&beancount_file, &entry).unwrap();
1017
1018        // Verify cache exists
1019        assert!(cache_path(&beancount_file).exists());
1020
1021        // Invalidate
1022        invalidate_cache(&beancount_file);
1023
1024        // Verify cache is gone
1025        assert!(!cache_path(&beancount_file).exists());
1026
1027        // Cleanup
1028        let _ = fs::remove_file(&beancount_file);
1029        let _ = fs::remove_dir(&temp_dir);
1030    }
1031
1032    #[test]
1033    fn test_invalidate_cache_removes_legacy_sidecar() {
1034        // invalidate_cache should remove both the new dotfile cache and any
1035        // pre-#939 visible cache file alongside the source.
1036        assert_clean_cache_env();
1037
1038        let temp_dir = std::env::temp_dir().join("rustledger_invalidate_legacy_test");
1039        let _ = fs::create_dir_all(&temp_dir);
1040
1041        let beancount_file = temp_dir.join("legacy.beancount");
1042        // Synthesize a leftover legacy cache file (no need to be valid — we're
1043        // only testing that invalidate removes it).
1044        let legacy = legacy_cache_path(&beancount_file);
1045        fs::write(&legacy, b"stale").unwrap();
1046        assert!(legacy.exists());
1047
1048        invalidate_cache(&beancount_file);
1049        assert!(
1050            !legacy.exists(),
1051            "invalidate_cache should remove the legacy sidecar file"
1052        );
1053
1054        let _ = fs::remove_dir(&temp_dir);
1055    }
1056
1057    #[test]
1058    fn test_load_cache_missing_file() {
1059        let missing = Path::new("/nonexistent/path/to/file.beancount");
1060        assert!(load_cache_entry(missing).is_none());
1061    }
1062
1063    #[test]
1064    fn test_load_cache_invalid_magic() {
1065        use std::io::Write;
1066
1067        assert_clean_cache_env();
1068
1069        let temp_dir = std::env::temp_dir().join("rustledger_magic_test");
1070        let _ = fs::create_dir_all(&temp_dir);
1071
1072        let beancount_file = temp_dir.join("test.beancount");
1073        // Write a malformed cache file at the path load_cache_entry will look up.
1074        let cache_file = cache_path(&beancount_file);
1075        let mut f = fs::File::create(&cache_file).unwrap();
1076        // Write invalid magic
1077        f.write_all(b"INVALID\0").unwrap();
1078        f.write_all(&[0u8; CacheHeader::SIZE - 8]).unwrap();
1079        drop(f);
1080
1081        assert!(load_cache_entry(&beancount_file).is_none());
1082
1083        // Cleanup
1084        let _ = fs::remove_file(&cache_file);
1085        let _ = fs::remove_dir(&temp_dir);
1086    }
1087
1088    /// Bumping `CACHE_VERSION` must short-circuit at the header so we
1089    /// never feed an older payload to rkyv with the newer schema. Writes
1090    /// a header with the correct magic but `version = CACHE_VERSION - 1`
1091    /// (e.g., v4 from before #1151's `Vec<Spanned<Posting>>` shape
1092    /// change) and asserts the loader refuses it.
1093    #[test]
1094    fn test_load_cache_rejects_older_version() {
1095        use std::io::Write;
1096
1097        assert_clean_cache_env();
1098
1099        let temp_dir = std::env::temp_dir().join("rustledger_old_version_test");
1100        let _ = fs::create_dir_all(&temp_dir);
1101
1102        let beancount_file = temp_dir.join("test.beancount");
1103        let cache_file = cache_path(&beancount_file);
1104        let mut f = fs::File::create(&cache_file).unwrap();
1105
1106        // Valid magic + previous CACHE_VERSION. The version check at
1107        // `load_cache_header` should refuse before any payload is
1108        // touched, no matter what the tail bytes look like.
1109        let stale_version: u32 = CACHE_VERSION.checked_sub(1).expect("CACHE_VERSION >= 1");
1110        f.write_all(CACHE_MAGIC).unwrap();
1111        f.write_all(&stale_version.to_le_bytes()).unwrap();
1112        f.write_all(&[0u8; CacheHeader::SIZE - 8 - 4]).unwrap();
1113        drop(f);
1114
1115        assert!(
1116            load_cache_entry(&beancount_file).is_none(),
1117            "loader must reject cache files with an older CACHE_VERSION"
1118        );
1119
1120        let _ = fs::remove_file(&cache_file);
1121        let _ = fs::remove_dir(&temp_dir);
1122    }
1123
1124    /// Frozen byte fixtures for the v8 cache layout of
1125    /// [`rustledger_core::CostNumber`].
1126    ///
1127    /// The intra-build distinctness test in `rustledger-core::cost`
1128    /// (`cost_number_archived_bytes_snapshot`) only catches drift
1129    /// where variants collide with each other. It would NOT catch a
1130    /// uniform encoding shift (e.g. a future rkyv minor bump that
1131    /// changes how `Archived<Decimal>` packs, or an accidental
1132    /// attribute change). When that happens every variant moves
1133    /// together so distinctness still holds, but user caches on disk
1134    /// silently fail to deserialize as garbage in the new layout.
1135    ///
1136    /// Capturing the exact bytes here pins the on-disk contract:
1137    /// any drift trips this test, forcing the developer to either
1138    /// (a) revert the encoding change, or (b) bump
1139    /// [`CACHE_VERSION`] so old cache files are short-circuited at
1140    /// the header check. The companion `cache_version_matches_v8`
1141    /// assertion below fires if a developer regenerates the fixtures
1142    /// without bumping the version constant in the same commit.
1143    ///
1144    /// **If this test fails** and you intend the new encoding to be
1145    /// the contract going forward: regenerate the fixtures by
1146    /// printing `rkyv::to_bytes(&cn)` for each variant, bump
1147    /// `CACHE_VERSION` to `9`, and update both the fixtures and the
1148    /// `cache_version_matches_v8` constant below in the same commit.
1149    ///
1150    /// Gated to little-endian targets — `rkyv::to_bytes` uses native
1151    /// endianness, so the hardcoded bytes are valid for `x86_64` /
1152    /// `aarch64` but would spuriously fail on big-endian platforms
1153    /// (`s390x`, `ppc64be`). `CACHE_VERSION`'s purpose is same-machine
1154    /// read guarding, so non-portable bytes aren't a real defect,
1155    /// just a test-portability footnote.
1156    #[cfg(target_endian = "little")]
1157    #[test]
1158    fn cost_number_archived_bytes_match_v8_fixtures() {
1159        use rust_decimal_macros::dec;
1160        use rustledger_core::{BookedCost, CostNumber};
1161
1162        // Tripwire: regenerating the byte fixtures below without
1163        // bumping CACHE_VERSION leaves users with rotten caches. The
1164        // assertion fires when CACHE_VERSION advances past 8, forcing
1165        // the developer to also update the fixtures (or remove this
1166        // tripwire if v9's contract is identical to v8 for CostNumber
1167        // — which is unusual but possible).
1168        // v9 (#1340), v10 (string escape-decoding), v11 (`MetaValue::Int`), and
1169        // v12 (`CachedOptions` field-parity) all bumped CACHE_VERSION without
1170        // touching the `CostNumber` archived layout these fixtures pin, so the
1171        // byte arrays below are still valid and only FIXTURE_VERSION moves.
1172        // v13 (#1700) ADDS `CostNumber::Compound` at the END of the enum:
1173        // existing discriminants and payload encodings are unchanged (the
1174        // arrays below still pin them), and a fixture for the new variant
1175        // joins them.
1176        // v15 (#1884) changes WHICH parse errors are emitted, not how anything
1177        // is archived, so the byte arrays below still pin the same encoding —
1178        // only the fixture version moves. The assertions after this one prove
1179        // that rather than assume it.
1180        // v19 (#1944) does the same for metadata values and balance
1181        // tolerances: values move, `CostNumber`'s archived layout does not.
1182        // v20 (#1930) widens the account-name character set; no archived
1183        // layout moves, only which inputs produce directives at all.
1184        // v18 (#1939) changes the cost-spec NUMBER a parse produces
1185        // (`{10.00 * 3 USD}` archives 30.00, not 10.00). That is a value, not a
1186        // layout: the `CostNumber` discriminants and payload encodings the byte
1187        // arrays below pin are untouched, and those assertions prove it rather
1188        // than take this comment's word for it.
1189        // v25 (#2008) is another v15: transaction headers beancount rejects now
1190        // produce a parse error. That changes WHICH errors are emitted, not how
1191        // a `CostNumber` is archived, so the byte arrays are still valid.
1192        const FIXTURE_VERSION: u32 = 29;
1193        assert_eq!(
1194            CACHE_VERSION, FIXTURE_VERSION,
1195            "CACHE_VERSION advanced past the fixture version; regenerate \
1196             the byte fixtures in this test and update FIXTURE_VERSION, \
1197             or remove the tripwire if v{CACHE_VERSION}'s CostNumber \
1198             encoding is byte-identical to the fixtures.",
1199        );
1200
1201        let cases: &[(&str, CostNumber, &[u8])] = &[
1202            (
1203                "PerUnit { value: 150 }",
1204                CostNumber::PerUnit { value: dec!(150) },
1205                &[
1206                    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,
1207                    0, 0, 0, 0, 0, 0, 0,
1208                ],
1209            ),
1210            (
1211                "Compound { per_unit: 5, total: 10 }",
1212                CostNumber::Compound {
1213                    per_unit: dec!(5),
1214                    total: dec!(10),
1215                },
1216                &[
1217                    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,
1218                    0, 0, 0, 0, 0, 0, 0,
1219                ],
1220            ),
1221            (
1222                "Total { value: 1500 }",
1223                CostNumber::Total { value: dec!(1500) },
1224                &[
1225                    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,
1226                    0, 0, 0, 0, 0, 0, 0,
1227                ],
1228            ),
1229            (
1230                "PerUnitFromTotal { per_unit: 150, total: 300 }",
1231                CostNumber::PerUnitFromTotal(BookedCost::new(dec!(150), dec!(300), dec!(2))),
1232                &[
1233                    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,
1234                    0, 0, 0, 0, 0, 0, 0, 0,
1235                ],
1236            ),
1237        ];
1238        let mut mismatches = Vec::new();
1239        for (name, cn, expected) in cases {
1240            let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(cn).unwrap();
1241            if bytes.as_ref() != *expected {
1242                mismatches.push(format!("  `{name}` → {:?}", bytes.as_ref()));
1243            }
1244        }
1245        assert!(
1246            mismatches.is_empty(),
1247            "rkyv layout drifted from v8 fixtures — bump CACHE_VERSION and \
1248             update the fixtures in this test if intentional. Actual bytes:\n{}",
1249            mismatches.join("\n"),
1250        );
1251    }
1252
1253    /// Layout-hash tripwire for [`rustledger_core::MetaValue`] — generalizes the
1254    /// `CostNumber` frozen-byte fixtures above to the metadata value type the
1255    /// cache also archives.
1256    ///
1257    /// The `CostNumber` fixtures only catch drift in cost numbers. A `MetaValue`
1258    /// variant reorder, or an rkyv encoding shift in how `InternedStr` / `String`
1259    /// / `Decimal` / `Amount` pack, changes the on-disk metadata bytes while
1260    /// `CostNumber` stays byte-identical — and `MetaValue::Int` (v11) was
1261    /// previously guarded only by a code comment, not a test. This hashes the
1262    /// archived bytes of one of every `MetaValue` variant (declaration order,
1263    /// length-prefixed) and pins the digest. Any archived-layout drift trips this,
1264    /// forcing the developer to bump `CACHE_VERSION` (so stale on-disk caches
1265    /// short-circuit at the header check) and regenerate the hash.
1266    ///
1267    /// Little-endian only, like the `CostNumber` fixtures — `rkyv::to_bytes` uses
1268    /// native endianness, and `CACHE_VERSION` guards same-machine reads.
1269    #[cfg(target_endian = "little")]
1270    #[test]
1271    fn meta_value_archived_layout_hash_matches() {
1272        use rustledger_core::{Account, Currency, Link, MetaValue, Tag};
1273
1274        // Tripwire: regenerating the hash without bumping CACHE_VERSION leaves
1275        // users with rotten metadata caches.
1276        // v13 (#1700) added a CostNumber variant; MetaValue's archived
1277        // layout is untouched, so per the tripwire contract only the
1278        // fixture version moves.
1279        // v15 (#1884) is a parser-diagnostics change with no layout impact —
1280        // same reasoning, and the hash assertion below is what verifies it.
1281        // v18 (#1939) evaluates arithmetic in a cost spec; `MetaValue` is not
1282        // involved at all, so the hash below must be unchanged — and is.
1283        // v19 (#1944) DOES touch metadata, but only which VALUE a given source
1284        // text produces (`2 * 3` -> Int(6) rather than Int(2)). The variants
1285        // and their archived encodings are untouched, so the hash below must
1286        // still match — and the assertion, not this comment, is what proves it.
1287        // v20 (#1930) is an account-name lexer change; `MetaValue` is
1288        // untouched and the hash below must still match.
1289        // v28 is the negative-zero parse rule: a literal `-0.00` now archives
1290        // an UNSIGNED zero. Like v19 that changes which VALUE a source text
1291        // produces, not the variants or their encodings, so the hash below
1292        // must still match — and the assertion, not this comment, proves it.
1293        const FIXTURE_VERSION: u32 = 29;
1294        const META_VALUE_LAYOUT_HASH: &str =
1295            "43e3c258fe376cede6a6c2c975100bcf67ddda0ab84b21566b123c01e0a54b25";
1296        assert_eq!(
1297            CACHE_VERSION, FIXTURE_VERSION,
1298            "CACHE_VERSION advanced past the MetaValue layout-hash fixture; if the \
1299             MetaValue archived layout changed, bump CACHE_VERSION and regenerate \
1300             META_VALUE_LAYOUT_HASH below in the same commit, else just bump \
1301             FIXTURE_VERSION.",
1302        );
1303
1304        // One value of every variant in declaration order. Each is archived alone
1305        // (no metadata map), so the bytes are deterministic.
1306        let variants: &[MetaValue] = &[
1307            MetaValue::String("USD".to_string()),
1308            MetaValue::Account(Account::from("Assets:Bank")),
1309            MetaValue::Currency(Currency::from("USD")),
1310            MetaValue::Tag(Tag::from("t")),
1311            MetaValue::Link(Link::from("t")),
1312            MetaValue::Date(rustledger_core::naive_date(2024, 1, 15).unwrap()),
1313            MetaValue::Number(dec!(42)),
1314            MetaValue::Bool(true),
1315            MetaValue::Amount(Amount::new(dec!(10), "USD")),
1316            MetaValue::None,
1317            MetaValue::Int(42),
1318        ];
1319
1320        let mut hasher = Hasher::new();
1321        for mv in variants {
1322            let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(mv).unwrap();
1323            // Length-prefix so a byte moving across a variant boundary can't be
1324            // masked by a compensating change in the neighbor.
1325            hasher.update(&(bytes.len() as u64).to_le_bytes());
1326            hasher.update(&bytes);
1327        }
1328        let digest = hasher.finalize().to_hex();
1329
1330        assert_eq!(
1331            digest.as_str(),
1332            META_VALUE_LAYOUT_HASH,
1333            "MetaValue archived layout changed. If intentional, bump CACHE_VERSION \
1334             and set META_VALUE_LAYOUT_HASH to: {digest}",
1335        );
1336    }
1337
1338    #[test]
1339    fn test_reintern_directives_deduplication() {
1340        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1341
1342        // Create multiple transactions with the same account
1343        let mut directives = vec![];
1344        for i in 0..5 {
1345            let txn = Transaction::new(date, format!("Txn {i}"))
1346                .with_synthesized_posting(Posting::new(
1347                    "Expenses:Food",
1348                    Amount::new(dec!(10.00), "USD"),
1349                ))
1350                .with_synthesized_posting(Posting::auto("Assets:Checking"));
1351            directives.push(Spanned::new(Directive::Transaction(txn), Span::new(0, 50)));
1352        }
1353
1354        // Re-intern should deduplicate the repeated account names and currencies
1355        let dedup_count = reintern_directives(&mut directives);
1356
1357        // We should have deduplicated:
1358        // - "Expenses:Food" appears 5 times but only first is new (4 dedup)
1359        // - "USD" appears 5 times but only first is new (4 dedup)
1360        // - "Assets:Checking" appears 5 times but only first is new (4 dedup)
1361        // Total: 12 deduplications
1362        assert_eq!(dedup_count, 12);
1363    }
1364
1365    /// The property `load_result_cached` relies on when it skips
1366    /// `reintern_directives`: deserializing under an `InternScope` leaves
1367    /// equal strings sharing one `Arc`, which is exactly what that pass
1368    /// exists to guarantee.
1369    ///
1370    /// Asserts the NEGATIVE half first. Without the scope every occurrence
1371    /// gets its own `Arc`, so if the scope ever stopped working this test
1372    /// would still be checking something real rather than passing because
1373    /// `ptr_eq` happened to hold for another reason.
1374    #[test]
1375    fn cache_hit_directives_share_one_arc_per_distinct_string() {
1376        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1377        let mut directives = vec![];
1378        for _ in 0..5 {
1379            // Every field the same, so each of the four categories below has
1380            // five occurrences of one string to share.
1381            let txn = Transaction::new(date, "SAME-NARRATION")
1382                .with_payee("SAME-PAYEE")
1383                .with_synthesized_posting(Posting::new(
1384                    "Expenses:Food",
1385                    Amount::new(dec!(10.00), "USD"),
1386                ))
1387                .with_synthesized_posting(Posting::auto("Assets:Checking"));
1388            directives.push(Spanned::new(Directive::Transaction(txn), Span::new(0, 50)));
1389        }
1390
1391        let bytes =
1392            rkyv::to_bytes::<rkyv::rancor::Error>(&directives).expect("directives serialize");
1393
1394        // All four categories of `InternedStr` a transaction carries, because
1395        // `reintern_directives` covers all of them and skipping it is only
1396        // sound if the scope does too. `account` reaches `AsInternedStr`
1397        // through the `Account` newtype and `currency` through `Amount`,
1398        // neither of which names the wrapper at the field, so covering one
1399        // does not imply covering the others.
1400        let pairs = |ds: &[Spanned<Directive>]| {
1401            let pick = |d: &Spanned<Directive>| match &d.value {
1402                Directive::Transaction(t) => {
1403                    let currency = match &t.postings[0].units {
1404                        Some(IncompleteAmount::Complete(a)) => a.currency.clone(),
1405                        other => panic!("expected complete units, got {other:?}"),
1406                    };
1407                    (
1408                        t.postings[0].account.clone(),
1409                        currency,
1410                        t.payee.clone().expect("payee"),
1411                        t.narration.clone(),
1412                    )
1413                }
1414                other => panic!("expected a transaction, got {other:?}"),
1415            };
1416            (pick(&ds[0]), pick(&ds[4]))
1417        };
1418
1419        let plain: Vec<Spanned<Directive>> =
1420            rkyv::from_bytes::<Vec<Spanned<Directive>>, rkyv::rancor::Error>(&bytes)
1421                .expect("deserialize without a scope");
1422        let (x, y) = pairs(&plain);
1423        assert_eq!(x.0.as_str(), y.0.as_str());
1424        assert!(
1425            !x.0.ptr_eq(&y.0) && !x.1.ptr_eq(&y.1) && !x.2.ptr_eq(&y.2) && !x.3.ptr_eq(&y.3),
1426            "without an InternScope each occurrence should get its own Arc - \
1427             if this now holds, the positive assertions below prove nothing"
1428        );
1429
1430        let scoped: Vec<Spanned<Directive>> = {
1431            let _intern = rustledger_core::intern::InternScope::new();
1432            rkyv::from_bytes::<Vec<Spanned<Directive>>, rkyv::rancor::Error>(&bytes)
1433                .expect("deserialize under a scope")
1434        };
1435        let (x, y) = pairs(&scoped);
1436        for (label, shared) in [
1437            ("account", x.0.ptr_eq(&y.0)),
1438            ("currency", x.1.ptr_eq(&y.1)),
1439            ("payee", x.2.ptr_eq(&y.2)),
1440            ("narration", x.3.ptr_eq(&y.3)),
1441        ] {
1442            assert!(
1443                shared,
1444                "under an InternScope {label} must share one Arc, which is \
1445                 what lets the cache-hit path skip reintern_directives"
1446            );
1447        }
1448    }
1449
1450    /// Deserialize `bytes` (optionally under a scope) and return the account
1451    /// of the first transaction. Interning only happens inside
1452    /// `AsInternedStr::deserialize_with`, so a scope test that builds an
1453    /// `InternedStr` directly proves nothing — `InternedStr::new` does not
1454    /// consult the scope at all.
1455    fn first_account(bytes: &[u8]) -> rustledger_core::Account {
1456        let ds = rkyv::from_bytes::<Vec<Spanned<Directive>>, rkyv::rancor::Error>(bytes)
1457            .expect("deserialize");
1458        match &ds[0].value {
1459            Directive::Transaction(t) => t.postings[0].account.clone(),
1460            other => panic!("expected a transaction, got {other:?}"),
1461        }
1462    }
1463
1464    /// The table must not outlive its guard, or a long-running host would
1465    /// accumulate every string it ever deserialized.
1466    #[test]
1467    fn the_intern_scope_stops_interning_once_the_guard_drops() {
1468        let bytes = one_txn_archive();
1469        let inside = {
1470            let _intern = rustledger_core::intern::InternScope::new();
1471            let a = first_account(&bytes);
1472            // Same scope, second deserialization: shares.
1473            assert!(first_account(&bytes).ptr_eq(&a));
1474            a
1475        };
1476        // The guard has dropped, so a fresh deserialization cannot reach the
1477        // table that produced `inside`.
1478        let after = first_account(&bytes);
1479        assert_eq!(inside.as_str(), after.as_str());
1480        assert!(
1481            !inside.ptr_eq(&after),
1482            "the table must be gone once the guard drops"
1483        );
1484    }
1485
1486    /// An inner scope must not pull the table out from under an outer one
1487    /// when it drops. `InternScope::new` returns a guard either way, so
1488    /// without the `installed` flag the inner `Drop` would clear the table
1489    /// and silently stop interning for the rest of the outer scope — which
1490    /// no assertion about a single scope would notice.
1491    #[test]
1492    fn a_nested_intern_scope_leaves_the_outer_one_interning() {
1493        let bytes = one_txn_archive();
1494        let outer = rustledger_core::intern::InternScope::new();
1495        let first = first_account(&bytes);
1496        {
1497            let _inner = rustledger_core::intern::InternScope::new();
1498            assert!(
1499                first_account(&bytes).ptr_eq(&first),
1500                "the inner scope should adopt the outer table, not replace it"
1501            );
1502        }
1503        assert!(
1504            first_account(&bytes).ptr_eq(&first),
1505            "the outer scope must still be interning after the inner guard drops"
1506        );
1507        drop(outer);
1508        assert!(!first_account(&bytes).ptr_eq(&first));
1509    }
1510
1511    /// One archived transaction, for the scope tests above.
1512    fn one_txn_archive() -> rkyv::util::AlignedVec {
1513        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1514        let txn = Transaction::new(date, "N")
1515            .with_synthesized_posting(Posting::new(
1516                "Expenses:Food",
1517                Amount::new(dec!(10.00), "USD"),
1518            ))
1519            .with_synthesized_posting(Posting::auto("Assets:Checking"));
1520        let ds = vec![Spanned::new(Directive::Transaction(txn), Span::new(0, 50))];
1521        rkyv::to_bytes::<rkyv::rancor::Error>(&ds).expect("serialize")
1522    }
1523
1524    #[test]
1525    fn test_cached_options_roundtrip() {
1526        let mut opts = Options::new();
1527        opts.title = Some("Test Ledger".to_string());
1528        opts.operating_currency = vec!["USD".to_string(), "EUR".to_string()];
1529        opts.render_commas = true;
1530
1531        let cached = CachedOptions::from(&opts);
1532        let restored: Options = cached.into();
1533
1534        assert_eq!(restored.title, Some("Test Ledger".to_string()));
1535        assert_eq!(restored.operating_currency, vec!["USD", "EUR"]);
1536        assert!(restored.render_commas);
1537    }
1538
1539    /// Structural guard (fitness function): populate EVERY non-transient
1540    /// `Options` field with a non-default value, round-trip through
1541    /// `CachedOptions`, and assert nothing was dropped. A new `Options` field
1542    /// that `CachedOptions` forgets to carry fails here — the bug class that
1543    /// silently dropped `display_precision` / `use_precise_interpolation` /
1544    /// `plugin_processing_mode` (and `set_options` before #1340).
1545    ///
1546    /// `warnings` is intentionally transient (re-derived, not cached), so it is
1547    /// left default on both sides. **When you add a field to `Options`, set it
1548    /// here too.**
1549    #[test]
1550    fn cached_options_field_parity() {
1551        use rust_decimal_macros::dec;
1552
1553        let mut opts = Options::new();
1554        opts.title = Some("T".into());
1555        opts.filename = Some("f.beancount".into());
1556        opts.operating_currency = vec!["USD".into(), "EUR".into()];
1557        opts.name_assets = "A".into();
1558        opts.name_liabilities = "L".into();
1559        opts.name_equity = "Q".into();
1560        opts.name_income = "I".into();
1561        opts.name_expenses = "X".into();
1562        opts.account_rounding = Some("Equity:Round".into());
1563        opts.account_previous_balances = "Opening".into();
1564        opts.account_previous_earnings = "Earn".into();
1565        opts.account_previous_conversions = "Conv".into();
1566        opts.account_current_earnings = "CurEarn".into();
1567        opts.account_current_conversions = Some("CurConv".into());
1568        opts.account_unrealized_gains = Some("Unreal".into());
1569        opts.conversion_currency = Some("NOTHING".into());
1570        opts.inferred_tolerance_default =
1571            std::iter::once(("USD".to_string(), dec!(0.005))).collect();
1572        opts.inferred_tolerance_multiplier = dec!(1.5);
1573        opts.infer_tolerance_from_cost = true;
1574        opts.use_legacy_fixed_tolerances = true;
1575        opts.experiment_explicit_tolerances = true;
1576        opts.use_precise_interpolation = true;
1577        opts.booking_method = "FIFO".into();
1578        opts.render_commas = true;
1579        opts.display_precision = [("USD".to_string(), 4u32), ("JPY".to_string(), 0)]
1580            .into_iter()
1581            .collect();
1582        opts.allow_pipe_separator = true;
1583        opts.long_string_maxlines = 99;
1584        opts.documents = vec!["docs".into()];
1585        opts.plugin_processing_mode = "raw".into();
1586        opts.custom = std::iter::once(("k".to_string(), "v".to_string())).collect();
1587        opts.set_options = std::iter::once("booking_method".to_string()).collect();
1588        // `warnings` left default (transient — not cached).
1589
1590        let restored: Options = CachedOptions::from(&opts).into();
1591        assert_eq!(
1592            restored, opts,
1593            "a CachedOptions field was dropped on the cache round-trip"
1594        );
1595    }
1596
1597    /// Regression for #1340: `set_options` must survive the cache
1598    /// round-trip. It gates `resolve_effective_booking_method`, so
1599    /// dropping it makes a cache hit re-book FIFO/LIFO ledgers as
1600    /// STRICT (the file-level `option "booking_method"` is ignored).
1601    #[test]
1602    fn test_cached_options_preserves_set_options_for_booking_method() {
1603        let mut opts = Options::new();
1604        // `set()` is what a parsed `option "booking_method" "FIFO"`
1605        // calls — it records both the value AND the set-membership.
1606        opts.set("booking_method", "FIFO");
1607        assert!(opts.set_options.contains("booking_method"));
1608
1609        let cached = CachedOptions::from(&opts);
1610        let restored: Options = cached.into();
1611
1612        assert_eq!(restored.booking_method, "FIFO");
1613        assert!(
1614            restored.set_options.contains("booking_method"),
1615            "set_options dropped across cache round-trip — booking method \
1616             resolution would fall back to the STRICT default on a cache hit"
1617        );
1618    }
1619
1620    #[test]
1621    fn test_cache_entry_file_paths() {
1622        let entry = CacheEntry {
1623            directives: vec![],
1624            options: CachedOptions::from(&Options::new()),
1625            plugins: vec![],
1626            files: vec![
1627                "/path/to/ledger.beancount".to_string(),
1628                "/path/to/include.beancount".to_string(),
1629            ],
1630        };
1631
1632        let paths = entry.file_paths();
1633        assert_eq!(paths.len(), 2);
1634        assert_eq!(paths[0], PathBuf::from("/path/to/ledger.beancount"));
1635        assert_eq!(paths[1], PathBuf::from("/path/to/include.beancount"));
1636    }
1637
1638    #[test]
1639    fn test_reintern_balance_directive() {
1640        use rustledger_core::Balance;
1641
1642        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1643        let balance = Balance::new(date, "Assets:Checking", Amount::new(dec!(1000.00), "USD"));
1644
1645        let mut directives = vec![
1646            Spanned::new(Directive::Balance(balance.clone()), Span::new(0, 50)),
1647            Spanned::new(Directive::Balance(balance), Span::new(51, 100)),
1648        ];
1649
1650        let dedup_count = reintern_directives(&mut directives);
1651        // Second occurrence of "Assets:Checking" and "USD" should be deduplicated
1652        assert_eq!(dedup_count, 2);
1653    }
1654
1655    #[test]
1656    fn test_reintern_open_close_directives() {
1657        use rustledger_core::{Close, Open};
1658
1659        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1660        let open = Open::new(date, "Assets:Checking");
1661        let close = Close::new(date, "Assets:Checking");
1662
1663        let mut directives = vec![
1664            Spanned::new(Directive::Open(open), Span::new(0, 50)),
1665            Spanned::new(Directive::Close(close), Span::new(51, 100)),
1666        ];
1667
1668        let dedup_count = reintern_directives(&mut directives);
1669        // Second "Assets:Checking" should be deduplicated
1670        assert_eq!(dedup_count, 1);
1671    }
1672}