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///   v30: options declared in an INCLUDED file are no longer applied unless
455///     they accumulate across includes (#2151). `Options` is part of the
456///     cached payload, so a v29 cache replays the old resolution and
457///     resurrects the behavior: a sub-ledger's `booking_method` still
458///     changing lot selection, a sub-ledger's `inferred_tolerance_default`
459///     still letting an unbalanced transaction pass. Caught exactly that way
460///     while testing the fix -- the matrices kept diverging until the stale
461///     caches were cleared.
462///
463/// v31: `Note` gained `tags` and `links` (#2160). The parser always accepted
464///     them on a note header and threw them away; now it keeps them, so the
465///     archived layout has two more fields. Without the bump a cache written
466///     by an older binary deserializes into the new struct and every note
467///     comes back with no tags -- the exact bug, resurrected from disk.
468///
469/// v32: a blank line before a `note` or `document` no longer eats its tags
470///     and links (#2160 review). Parser OUTPUT, not layout: the archived
471///     shape is v31's, but a v31 cache holds the empty tag lists the old
472///     parser produced, and this build would serve them as fact.
473///
474/// v33: `~` ends a balance's amount expression, so `0.25 + 0.75 ~ 0.01 USD`
475///     parses instead of erroring; and a `price` now refuses a tolerance it
476///     used to accept and half-read (#2191). Parser OUTPUT in BOTH
477///     directions: a v32 cache holds the error for the balance and the
478///     truncated 1.10 for the price, and this build produces neither.
479///
480/// v34: a `query` carrying a tag or link is now diagnosed (#2194). The
481///     DIRECTIVE is unchanged -- `reject_tags_and_links` records an error and
482///     conversion still emits the `Query`, as it does for the other seven
483///     directives that refuse tags. What moves is the error list, and that is
484///     enough: a file with a tagged query used to parse clean and was
485///     therefore cacheable, so a v33 blob exists for it, and replaying that
486///     blob skips the parse that would now complain. Measured rather than
487///     assumed: a pre-#2194 binary run on a tagged query reports 0 errors and
488///     writes a cache, and this build reading that same blob reports 0 errors
489///     WITHOUT this bump and 2 WITH it. The bump is what surfaces the
490///     diagnostic.
491///
492/// v35: a balance tolerance whose currency disagrees with the amount's, or
493///     which carries a second juxtaposed number, is diagnosed instead of
494///     read in part and discarded in part (#2193). Like v34 the DIRECTIVE is
495///     unchanged and the error list is what moves — and like v34 that is
496///     enough, because such a file used to parse clean and was therefore
497///     cacheable, so replaying its v34 blob skips the parse that now
498///     complains.
499///
500/// Public so `rustledger-wasm` can pin its own cache version against this one.
501/// Both caches archive the same `Vec<Directive>`, so a parser change that
502/// alters PARSER OUTPUT has to bump both — and on #1942 only this one was
503/// bumped, which review caught rather than any test. See
504/// `loader_cache_version_is_pinned` in `rustledger-wasm/src/cache.rs`.
505pub const CACHE_VERSION: u32 = 35;
506
507/// Cache header stored at the start of cache files.
508#[derive(Debug, Clone)]
509struct CacheHeader {
510    /// Magic bytes for identification.
511    magic: [u8; 8],
512    /// Cache format version.
513    version: u32,
514    /// BLAKE3 hash of source files (path + mtime + size).
515    hash: [u8; 32],
516    /// Length of the serialized data.
517    data_len: u64,
518}
519
520impl CacheHeader {
521    const SIZE: usize = 8 + 4 + 32 + 8;
522
523    fn to_bytes(&self) -> [u8; Self::SIZE] {
524        let mut buf = [0u8; Self::SIZE];
525        buf[0..8].copy_from_slice(&self.magic);
526        buf[8..12].copy_from_slice(&self.version.to_le_bytes());
527        buf[12..44].copy_from_slice(&self.hash);
528        buf[44..52].copy_from_slice(&self.data_len.to_le_bytes());
529        buf
530    }
531
532    fn from_bytes(bytes: &[u8]) -> Option<Self> {
533        if bytes.len() < Self::SIZE {
534            return None;
535        }
536
537        let mut magic = [0u8; 8];
538        magic.copy_from_slice(&bytes[0..8]);
539
540        let version = u32::from_le_bytes(bytes[8..12].try_into().ok()?);
541
542        let mut hash = [0u8; 32];
543        hash.copy_from_slice(&bytes[12..44]);
544
545        let data_len = u64::from_le_bytes(bytes[44..52].try_into().ok()?);
546
547        Some(Self {
548            magic,
549            version,
550            hash,
551            data_len,
552        })
553    }
554}
555
556/// Compute a hash of the given files and their modification times.
557///
558/// Files whose metadata cannot be read (e.g., deleted between load and cache)
559/// contribute only their path to the hash. This is intentional — the resulting
560/// hash mismatch will cause a cache miss on next load.
561fn compute_hash(files: &[&Path]) -> [u8; 32] {
562    let mut hasher = Hasher::new();
563
564    for file in files {
565        // Hash the file path
566        hasher.update(file.to_string_lossy().as_bytes());
567
568        // Hash the modification time (skip silently if inaccessible)
569        if let Ok(metadata) = fs::metadata(file) {
570            if let Ok(mtime) = metadata.modified()
571                && let Ok(duration) = mtime.duration_since(std::time::UNIX_EPOCH)
572            {
573                hasher.update(&duration.as_secs().to_le_bytes());
574                hasher.update(&duration.subsec_nanos().to_le_bytes());
575            }
576            // Hash the file size
577            hasher.update(&metadata.len().to_le_bytes());
578        }
579    }
580
581    *hasher.finalize().as_bytes()
582}
583
584/// Environment variable that overrides the default cache filename pattern.
585///
586/// The value is a path that may contain `{filename}` as a placeholder for the
587/// source file's basename. Relative paths are resolved against the source
588/// file's directory; absolute paths are used as-is. Mirrors Python beancount's
589/// `BEANCOUNT_LOAD_CACHE_FILENAME`.
590pub const CACHE_FILENAME_ENV: &str = "BEANCOUNT_LOAD_CACHE_FILENAME";
591
592/// Environment variable that disables the binary cache entirely when set.
593///
594/// Mirrors Python beancount's `BEANCOUNT_DISABLE_LOAD_CACHE`.
595pub const DISABLE_CACHE_ENV: &str = "BEANCOUNT_DISABLE_LOAD_CACHE";
596
597/// Returns the cache file path for a given source file.
598///
599/// Resolution order:
600/// 1. If `BEANCOUNT_LOAD_CACHE_FILENAME` is set, substitute `{filename}` with
601///    the source basename and resolve relative paths against the source dir.
602/// 2. Otherwise, default to a hidden dotfile alongside the source via
603///    [`default_cache_path`]: `path/to/main.beancount` →
604///    `path/to/.main.beancount.cache`.
605///
606/// The dotfile prefix matches Python beancount's `.{filename}.picklecache`
607/// convention, so the cache stays out of the way of `ls` and most file
608/// explorers without breaking from the established beancount ecosystem
609/// behavior. See issue #939.
610///
611/// This function reads process env. Tests that need a deterministic path
612/// regardless of the caller's environment should use [`default_cache_path`]
613/// directly.
614pub fn cache_path(source: &Path) -> PathBuf {
615    if let Ok(pattern) = std::env::var(CACHE_FILENAME_ENV)
616        && !pattern.is_empty()
617    {
618        return resolve_cache_pattern(source, &pattern);
619    }
620    default_cache_path(source)
621}
622
623/// Returns the default cache file path (no env-var lookup).
624///
625/// Use this when you need a path that is independent of process env, e.g.
626/// in tests that mustn't be perturbed by a developer's
627/// `BEANCOUNT_LOAD_CACHE_FILENAME`.
628#[must_use]
629pub fn default_cache_path(source: &Path) -> PathBuf {
630    let mut path = source.to_path_buf();
631    let name = path.file_name().map_or_else(
632        || ".ledger.cache".to_string(),
633        |n| format!(".{}.cache", n.to_string_lossy()),
634    );
635    path.set_file_name(name);
636    path
637}
638
639/// Resolve a `BEANCOUNT_LOAD_CACHE_FILENAME` pattern against a source path.
640///
641/// The `"{filename}"` token below is a literal user-facing substitution
642/// placeholder (matching Python beancount), not a `format!` argument — hence
643/// the explicit allow.
644#[allow(clippy::literal_string_with_formatting_args)]
645fn resolve_cache_pattern(source: &Path, pattern: &str) -> PathBuf {
646    let filename = source.file_name().map_or_else(
647        || "ledger".to_string(),
648        |n| n.to_string_lossy().into_owned(),
649    );
650    let resolved = pattern.replace("{filename}", &filename);
651    let p = PathBuf::from(&resolved);
652    if p.is_absolute() {
653        return p;
654    }
655    source.parent().map_or(p.clone(), |parent| parent.join(&p))
656}
657
658/// Returns the legacy (pre-#939) cache path: `<source>.cache` alongside source.
659///
660/// Used by `save_cache_entry` to opportunistically clean up stale cache files
661/// from earlier rustledger versions. Not part of the lookup path.
662fn legacy_cache_path(source: &Path) -> PathBuf {
663    let mut path = source.to_path_buf();
664    let name = path.file_name().map_or_else(
665        || "ledger.cache".to_string(),
666        |n| format!("{}.cache", n.to_string_lossy()),
667    );
668    path.set_file_name(name);
669    path
670}
671
672/// Returns true if `BEANCOUNT_DISABLE_LOAD_CACHE` is set in the environment.
673///
674/// Mere presence disables — value is ignored, including empty string. Matches
675/// Python beancount's `os.getenv("BEANCOUNT_DISABLE_LOAD_CACHE") is None`
676/// check.
677#[must_use]
678pub fn cache_disabled_by_env() -> bool {
679    std::env::var_os(DISABLE_CACHE_ENV).is_some()
680}
681
682/// Try to load a cache entry from disk.
683///
684/// Returns `Some(CacheEntry)` if cache is valid and file hashes match,
685/// `None` if cache is missing, invalid, outdated, or
686/// `BEANCOUNT_DISABLE_LOAD_CACHE` is set.
687pub fn load_cache_entry(main_file: &Path) -> Option<CacheEntry> {
688    if cache_disabled_by_env() {
689        return None;
690    }
691    let cache_file = cache_path(main_file);
692    let mut file = fs::File::open(&cache_file).ok()?;
693
694    // Read header
695    let mut header_bytes = [0u8; CacheHeader::SIZE];
696    file.read_exact(&mut header_bytes).ok()?;
697    let header = CacheHeader::from_bytes(&header_bytes)?;
698
699    // Validate magic and version
700    if header.magic != *CACHE_MAGIC {
701        return None;
702    }
703    if header.version != CACHE_VERSION {
704        return None;
705    }
706
707    // Read data
708    let mut data = vec![0u8; header.data_len as usize];
709    file.read_exact(&mut data).ok()?;
710
711    // Deserialize
712    // Intern while deserializing rather than deduplicating afterwards.
713    // rkyv's deserializer carries no interner, so `AsInternedStr` handed
714    // every occurrence its own `Arc<str>` — 40,015 of them on a
715    // 10,000-transaction ledger holding a few dozen distinct strings — and
716    // the caller then walked every directive again through
717    // `reintern_directives` to collapse them. The scope establishes the same
718    // postcondition (equal strings share a pointer) on the way in, so the
719    // second walk is redundant on this path; see `load_result_cached`.
720    //
721    // The guard drops at the end of this function, including on the `?`
722    // paths below, so nothing outlives the load.
723    let entry: CacheEntry = {
724        let _intern = rustledger_core::intern::InternScope::new();
725        rkyv::from_bytes::<CacheEntry, rkyv::rancor::Error>(&data).ok()?
726    };
727
728    // Validate hash against the files stored in the cache
729    let file_paths = entry.file_paths();
730    let file_refs: Vec<&Path> = file_paths.iter().map(PathBuf::as_path).collect();
731    let expected_hash = compute_hash(&file_refs);
732    if header.hash != expected_hash {
733        return None;
734    }
735
736    Some(entry)
737}
738
739/// Save a cache entry to disk.
740///
741/// No-op (returns Ok) when `BEANCOUNT_DISABLE_LOAD_CACHE` is set.
742pub fn save_cache_entry(main_file: &Path, entry: &CacheEntry) -> Result<(), std::io::Error> {
743    if cache_disabled_by_env() {
744        return Ok(());
745    }
746    let cache_file = cache_path(main_file);
747
748    // Compute hash from the files in the entry
749    let file_paths = entry.file_paths();
750    let file_refs: Vec<&Path> = file_paths.iter().map(PathBuf::as_path).collect();
751    let hash = compute_hash(&file_refs);
752
753    // Serialize
754    let data = rkyv::to_bytes::<rkyv::rancor::Error>(entry)
755        .map(|v| v.to_vec())
756        .map_err(|e| std::io::Error::other(e.to_string()))?;
757
758    // Write header + data
759    let header = CacheHeader {
760        magic: *CACHE_MAGIC,
761        version: CACHE_VERSION,
762        hash,
763        data_len: data.len() as u64,
764    };
765
766    // Custom BEANCOUNT_LOAD_CACHE_FILENAME patterns can point at a directory
767    // that doesn't exist yet (e.g. ~/.cache/rledger/foo.cache on a fresh
768    // install). Create the parent eagerly so caching isn't silently disabled.
769    if let Some(parent) = cache_file.parent()
770        && !parent.as_os_str().is_empty()
771    {
772        fs::create_dir_all(parent)?;
773    }
774
775    let mut file = fs::File::create(&cache_file)?;
776    file.write_all(&header.to_bytes())?;
777    file.write_all(&data)?;
778
779    // One-shot cleanup of pre-#939 visible cache files. Only attempt when the
780    // legacy path differs from the new path (i.e., we're not using a custom
781    // pattern that happens to land on the old name) and silently ignore
782    // failures — leaving the file is harmless, just untidy.
783    let legacy = legacy_cache_path(main_file);
784    if legacy != cache_file && legacy.exists() {
785        let _ = fs::remove_file(&legacy);
786    }
787
788    Ok(())
789}
790
791/// Serialize directives to bytes using rkyv (for benchmarking).
792#[cfg(test)]
793fn serialize_directives(directives: &Vec<Spanned<Directive>>) -> Result<Vec<u8>, std::io::Error> {
794    rkyv::to_bytes::<rkyv::rancor::Error>(directives)
795        .map(|v| v.to_vec())
796        .map_err(|e| std::io::Error::other(e.to_string()))
797}
798
799/// Deserialize directives from bytes using rkyv (for benchmarking).
800#[cfg(test)]
801fn deserialize_directives(data: &[u8]) -> Option<Vec<Spanned<Directive>>> {
802    rkyv::from_bytes::<Vec<Spanned<Directive>>, rkyv::rancor::Error>(data).ok()
803}
804
805/// Invalidate the cache for a file.
806///
807/// Removes both the current cache file and any legacy pre-#939
808/// `<file>.cache` sidecar so a subsequent load can't pick up stale data.
809pub fn invalidate_cache(main_file: &Path) {
810    let cache_file = cache_path(main_file);
811    let _ = fs::remove_file(&cache_file);
812
813    let legacy = legacy_cache_path(main_file);
814    if legacy != cache_file {
815        let _ = fs::remove_file(&legacy);
816    }
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822    use crate::dedup::reintern_directives;
823    use rust_decimal_macros::dec;
824    use rustledger_core::{Amount, IncompleteAmount, Posting, Transaction};
825    use rustledger_parser::Span;
826
827    #[test]
828    fn test_cache_header_roundtrip() {
829        let header = CacheHeader {
830            magic: *CACHE_MAGIC,
831            version: CACHE_VERSION,
832            hash: [42u8; 32],
833            data_len: 12345,
834        };
835
836        let bytes = header.to_bytes();
837        let parsed = CacheHeader::from_bytes(&bytes).unwrap();
838
839        assert_eq!(parsed.magic, header.magic);
840        assert_eq!(parsed.version, header.version);
841        assert_eq!(parsed.hash, header.hash);
842        assert_eq!(parsed.data_len, header.data_len);
843    }
844
845    #[test]
846    fn test_compute_hash_deterministic() {
847        let files: Vec<&Path> = vec![];
848        let hash1 = compute_hash(&files);
849        let hash2 = compute_hash(&files);
850        assert_eq!(hash1, hash2);
851    }
852
853    #[test]
854    fn test_serialize_deserialize_roundtrip() {
855        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
856
857        let txn = Transaction::new(date, "Test transaction")
858            .with_payee("Test Payee")
859            .with_synthesized_posting(Posting::new(
860                "Expenses:Test",
861                Amount::new(dec!(100.00), "USD"),
862            ))
863            .with_synthesized_posting(Posting::auto("Assets:Checking"));
864
865        let directives = vec![Spanned::new(Directive::Transaction(txn), Span::new(0, 100))];
866
867        // Serialize
868        let serialized = serialize_directives(&directives).expect("serialization failed");
869
870        // Deserialize
871        let deserialized = deserialize_directives(&serialized).expect("deserialization failed");
872
873        // Verify roundtrip
874        assert_eq!(directives.len(), deserialized.len());
875        let orig_txn = directives[0].value.as_transaction().unwrap();
876        let deser_txn = deserialized[0].value.as_transaction().unwrap();
877
878        assert_eq!(orig_txn.date, deser_txn.date);
879        assert_eq!(orig_txn.payee, deser_txn.payee);
880        assert_eq!(orig_txn.narration, deser_txn.narration);
881        assert_eq!(orig_txn.postings.len(), deser_txn.postings.len());
882
883        // Check first posting
884        assert_eq!(orig_txn.postings[0].account, deser_txn.postings[0].account);
885        assert_eq!(orig_txn.postings[0].units, deser_txn.postings[0].units);
886    }
887
888    #[test]
889    #[ignore = "manual benchmark - run with: cargo test -p rustledger-loader --release -- --ignored --nocapture"]
890    fn bench_cache_performance() {
891        // Generate test directives
892        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
893        let mut directives = Vec::with_capacity(10000);
894
895        for i in 0..10000 {
896            let txn = Transaction::new(date, format!("Transaction {i}"))
897                .with_payee("Store")
898                .with_synthesized_posting(Posting::new(
899                    "Expenses:Food",
900                    Amount::new(dec!(25.00), "USD"),
901                ))
902                .with_synthesized_posting(Posting::auto("Assets:Checking"));
903
904            directives.push(Spanned::new(Directive::Transaction(txn), Span::new(0, 100)));
905        }
906
907        println!("\n=== Cache Benchmark (10,000 directives) ===");
908
909        // Benchmark serialization
910        let start = std::time::Instant::now();
911        let serialized = serialize_directives(&directives).unwrap();
912        let serialize_time = start.elapsed();
913        println!(
914            "Serialize: {:?} ({:.2} MB)",
915            serialize_time,
916            serialized.len() as f64 / 1_000_000.0
917        );
918
919        // Benchmark deserialization
920        let start = std::time::Instant::now();
921        let deserialized = deserialize_directives(&serialized).unwrap();
922        let deserialize_time = start.elapsed();
923        println!("Deserialize: {deserialize_time:?}");
924
925        assert_eq!(directives.len(), deserialized.len());
926
927        println!(
928            "\nSpeedup potential: If parsing takes 100ms, cache load would be {:.1}x faster",
929            100.0 / deserialize_time.as_millis() as f64
930        );
931    }
932
933    // Note: end-to-end coverage of `cache_path()` (including the
934    // `BEANCOUNT_LOAD_CACHE_FILENAME` env var) lives in
935    // `tests/cache_env_var_test.rs`, which can mutate process env without
936    // tripping the crate's `forbid(unsafe_code)`. The tests below cover the
937    // pure pattern-resolution logic and the legacy-path helper.
938
939    /// Fail fast if a developer has set the cache env vars locally — the
940    /// roundtrip tests in this module call `save_cache_entry`/`invalidate_cache`
941    /// which read process env, and a custom pattern would silently redirect
942    /// writes elsewhere (or fail in surprising ways). CI runs with a clean env.
943    fn assert_clean_cache_env() {
944        for var in [CACHE_FILENAME_ENV, DISABLE_CACHE_ENV] {
945            assert!(
946                std::env::var_os(var).is_none(),
947                "unset {var} before running this test"
948            );
949        }
950    }
951
952    #[test]
953    fn test_resolve_cache_pattern_relative_with_substitution() {
954        let source = Path::new("/home/user/finances/main.beancount");
955        let resolved = resolve_cache_pattern(source, ".cache/{filename}.bin");
956        assert_eq!(
957            resolved,
958            Path::new("/home/user/finances/.cache/main.beancount.bin")
959        );
960    }
961
962    #[test]
963    fn test_resolve_cache_pattern_absolute() {
964        let source = Path::new("/home/user/main.beancount");
965        let resolved = resolve_cache_pattern(source, "/var/cache/rledger/{filename}.cache");
966        assert_eq!(
967            resolved,
968            Path::new("/var/cache/rledger/main.beancount.cache")
969        );
970    }
971
972    #[test]
973    fn test_resolve_cache_pattern_no_substitution() {
974        // Pattern without {filename} is used verbatim.
975        let source = Path::new("/home/user/main.beancount");
976        let resolved = resolve_cache_pattern(source, "fixed.cache");
977        assert_eq!(resolved, Path::new("/home/user/fixed.cache"));
978    }
979
980    #[test]
981    fn test_legacy_cache_path() {
982        let source = Path::new("/tmp/ledger.beancount");
983        assert_eq!(
984            legacy_cache_path(source),
985            Path::new("/tmp/ledger.beancount.cache")
986        );
987    }
988
989    #[test]
990    fn test_save_load_cache_entry_roundtrip() {
991        use std::io::Write;
992
993        assert_clean_cache_env();
994
995        // Create a temp directory
996        let temp_dir = std::env::temp_dir().join("rustledger_cache_test");
997        let _ = fs::create_dir_all(&temp_dir);
998
999        // Create a temp beancount file
1000        let beancount_file = temp_dir.join("test.beancount");
1001        let mut f = fs::File::create(&beancount_file).unwrap();
1002        writeln!(f, "2024-01-01 open Assets:Test").unwrap();
1003        drop(f);
1004
1005        // Create a cache entry
1006        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1007        let txn =
1008            Transaction::new(date, "Test").with_synthesized_posting(Posting::auto("Assets:Test"));
1009        let directives = vec![Spanned::new(Directive::Transaction(txn), Span::new(0, 50))];
1010
1011        let entry = CacheEntry {
1012            directives,
1013            options: CachedOptions::from(&Options::new()),
1014            plugins: vec![CachedPlugin {
1015                name: "test_plugin".to_string(),
1016                config: Some("config".to_string()),
1017                force_python: false,
1018            }],
1019            files: vec![beancount_file.to_string_lossy().to_string()],
1020        };
1021
1022        // Save cache
1023        save_cache_entry(&beancount_file, &entry).expect("save failed");
1024
1025        // Load cache
1026        let loaded = load_cache_entry(&beancount_file).expect("load failed");
1027
1028        // Verify
1029        assert_eq!(loaded.directives.len(), entry.directives.len());
1030        assert_eq!(loaded.plugins.len(), 1);
1031        assert_eq!(loaded.plugins[0].name, "test_plugin");
1032        assert_eq!(loaded.plugins[0].config, Some("config".to_string()));
1033        assert_eq!(loaded.files.len(), 1);
1034
1035        // Cleanup
1036        let _ = fs::remove_file(&beancount_file);
1037        let _ = fs::remove_file(cache_path(&beancount_file));
1038        let _ = fs::remove_dir(&temp_dir);
1039    }
1040
1041    #[test]
1042    fn test_invalidate_cache() {
1043        use std::io::Write;
1044
1045        assert_clean_cache_env();
1046
1047        let temp_dir = std::env::temp_dir().join("rustledger_invalidate_test");
1048        let _ = fs::create_dir_all(&temp_dir);
1049
1050        let beancount_file = temp_dir.join("test.beancount");
1051        let mut f = fs::File::create(&beancount_file).unwrap();
1052        writeln!(f, "2024-01-01 open Assets:Test").unwrap();
1053        drop(f);
1054
1055        // Create and save a cache
1056        let entry = CacheEntry {
1057            directives: vec![],
1058            options: CachedOptions::from(&Options::new()),
1059            plugins: vec![],
1060            files: vec![beancount_file.to_string_lossy().to_string()],
1061        };
1062        save_cache_entry(&beancount_file, &entry).unwrap();
1063
1064        // Verify cache exists
1065        assert!(cache_path(&beancount_file).exists());
1066
1067        // Invalidate
1068        invalidate_cache(&beancount_file);
1069
1070        // Verify cache is gone
1071        assert!(!cache_path(&beancount_file).exists());
1072
1073        // Cleanup
1074        let _ = fs::remove_file(&beancount_file);
1075        let _ = fs::remove_dir(&temp_dir);
1076    }
1077
1078    #[test]
1079    fn test_invalidate_cache_removes_legacy_sidecar() {
1080        // invalidate_cache should remove both the new dotfile cache and any
1081        // pre-#939 visible cache file alongside the source.
1082        assert_clean_cache_env();
1083
1084        let temp_dir = std::env::temp_dir().join("rustledger_invalidate_legacy_test");
1085        let _ = fs::create_dir_all(&temp_dir);
1086
1087        let beancount_file = temp_dir.join("legacy.beancount");
1088        // Synthesize a leftover legacy cache file (no need to be valid — we're
1089        // only testing that invalidate removes it).
1090        let legacy = legacy_cache_path(&beancount_file);
1091        fs::write(&legacy, b"stale").unwrap();
1092        assert!(legacy.exists());
1093
1094        invalidate_cache(&beancount_file);
1095        assert!(
1096            !legacy.exists(),
1097            "invalidate_cache should remove the legacy sidecar file"
1098        );
1099
1100        let _ = fs::remove_dir(&temp_dir);
1101    }
1102
1103    #[test]
1104    fn test_load_cache_missing_file() {
1105        let missing = Path::new("/nonexistent/path/to/file.beancount");
1106        assert!(load_cache_entry(missing).is_none());
1107    }
1108
1109    #[test]
1110    fn test_load_cache_invalid_magic() {
1111        use std::io::Write;
1112
1113        assert_clean_cache_env();
1114
1115        let temp_dir = std::env::temp_dir().join("rustledger_magic_test");
1116        let _ = fs::create_dir_all(&temp_dir);
1117
1118        let beancount_file = temp_dir.join("test.beancount");
1119        // Write a malformed cache file at the path load_cache_entry will look up.
1120        let cache_file = cache_path(&beancount_file);
1121        let mut f = fs::File::create(&cache_file).unwrap();
1122        // Write invalid magic
1123        f.write_all(b"INVALID\0").unwrap();
1124        f.write_all(&[0u8; CacheHeader::SIZE - 8]).unwrap();
1125        drop(f);
1126
1127        assert!(load_cache_entry(&beancount_file).is_none());
1128
1129        // Cleanup
1130        let _ = fs::remove_file(&cache_file);
1131        let _ = fs::remove_dir(&temp_dir);
1132    }
1133
1134    /// Bumping `CACHE_VERSION` must short-circuit at the header so we
1135    /// never feed an older payload to rkyv with the newer schema. Writes
1136    /// a header with the correct magic but `version = CACHE_VERSION - 1`
1137    /// (e.g., v4 from before #1151's `Vec<Spanned<Posting>>` shape
1138    /// change) and asserts the loader refuses it.
1139    #[test]
1140    fn test_load_cache_rejects_older_version() {
1141        use std::io::Write;
1142
1143        assert_clean_cache_env();
1144
1145        let temp_dir = std::env::temp_dir().join("rustledger_old_version_test");
1146        let _ = fs::create_dir_all(&temp_dir);
1147
1148        let beancount_file = temp_dir.join("test.beancount");
1149        let cache_file = cache_path(&beancount_file);
1150        let mut f = fs::File::create(&cache_file).unwrap();
1151
1152        // Valid magic + previous CACHE_VERSION. The version check at
1153        // `load_cache_header` should refuse before any payload is
1154        // touched, no matter what the tail bytes look like.
1155        let stale_version: u32 = CACHE_VERSION.checked_sub(1).expect("CACHE_VERSION >= 1");
1156        f.write_all(CACHE_MAGIC).unwrap();
1157        f.write_all(&stale_version.to_le_bytes()).unwrap();
1158        f.write_all(&[0u8; CacheHeader::SIZE - 8 - 4]).unwrap();
1159        drop(f);
1160
1161        assert!(
1162            load_cache_entry(&beancount_file).is_none(),
1163            "loader must reject cache files with an older CACHE_VERSION"
1164        );
1165
1166        let _ = fs::remove_file(&cache_file);
1167        let _ = fs::remove_dir(&temp_dir);
1168    }
1169
1170    /// Frozen byte fixtures for the v8 cache layout of
1171    /// [`rustledger_core::CostNumber`].
1172    ///
1173    /// The intra-build distinctness test in `rustledger-core::cost`
1174    /// (`cost_number_archived_bytes_snapshot`) only catches drift
1175    /// where variants collide with each other. It would NOT catch a
1176    /// uniform encoding shift (e.g. a future rkyv minor bump that
1177    /// changes how `Archived<Decimal>` packs, or an accidental
1178    /// attribute change). When that happens every variant moves
1179    /// together so distinctness still holds, but user caches on disk
1180    /// silently fail to deserialize as garbage in the new layout.
1181    ///
1182    /// Capturing the exact bytes here pins the on-disk contract:
1183    /// any drift trips this test, forcing the developer to either
1184    /// (a) revert the encoding change, or (b) bump
1185    /// [`CACHE_VERSION`] so old cache files are short-circuited at
1186    /// the header check. The companion `cache_version_matches_v8`
1187    /// assertion below fires if a developer regenerates the fixtures
1188    /// without bumping the version constant in the same commit.
1189    ///
1190    /// **If this test fails** and you intend the new encoding to be
1191    /// the contract going forward: regenerate the fixtures by
1192    /// printing `rkyv::to_bytes(&cn)` for each variant, bump
1193    /// `CACHE_VERSION` to `9`, and update both the fixtures and the
1194    /// `cache_version_matches_v8` constant below in the same commit.
1195    ///
1196    /// Gated to little-endian targets — `rkyv::to_bytes` uses native
1197    /// endianness, so the hardcoded bytes are valid for `x86_64` /
1198    /// `aarch64` but would spuriously fail on big-endian platforms
1199    /// (`s390x`, `ppc64be`). `CACHE_VERSION`'s purpose is same-machine
1200    /// read guarding, so non-portable bytes aren't a real defect,
1201    /// just a test-portability footnote.
1202    #[cfg(target_endian = "little")]
1203    #[test]
1204    fn cost_number_archived_bytes_match_v8_fixtures() {
1205        use rust_decimal_macros::dec;
1206        use rustledger_core::{BookedCost, CostNumber};
1207
1208        // Tripwire: regenerating the byte fixtures below without
1209        // bumping CACHE_VERSION leaves users with rotten caches. The
1210        // assertion fires when CACHE_VERSION advances past 8, forcing
1211        // the developer to also update the fixtures (or remove this
1212        // tripwire if v9's contract is identical to v8 for CostNumber
1213        // — which is unusual but possible).
1214        // v9 (#1340), v10 (string escape-decoding), v11 (`MetaValue::Int`), and
1215        // v12 (`CachedOptions` field-parity) all bumped CACHE_VERSION without
1216        // touching the `CostNumber` archived layout these fixtures pin, so the
1217        // byte arrays below are still valid and only FIXTURE_VERSION moves.
1218        // v13 (#1700) ADDS `CostNumber::Compound` at the END of the enum:
1219        // existing discriminants and payload encodings are unchanged (the
1220        // arrays below still pin them), and a fixture for the new variant
1221        // joins them.
1222        // v15 (#1884) changes WHICH parse errors are emitted, not how anything
1223        // is archived, so the byte arrays below still pin the same encoding —
1224        // only the fixture version moves. The assertions after this one prove
1225        // that rather than assume it.
1226        // v19 (#1944) does the same for metadata values and balance
1227        // tolerances: values move, `CostNumber`'s archived layout does not.
1228        // v20 (#1930) widens the account-name character set; no archived
1229        // layout moves, only which inputs produce directives at all.
1230        // v18 (#1939) changes the cost-spec NUMBER a parse produces
1231        // (`{10.00 * 3 USD}` archives 30.00, not 10.00). That is a value, not a
1232        // layout: the `CostNumber` discriminants and payload encodings the byte
1233        // arrays below pin are untouched, and those assertions prove it rather
1234        // than take this comment's word for it.
1235        // v25 (#2008) is another v15: transaction headers beancount rejects now
1236        // produce a parse error. That changes WHICH errors are emitted, not how
1237        // a `CostNumber` is archived, so the byte arrays are still valid.
1238        // v30 (#2151) is the same shape again: options declared in an included
1239        // file stop being applied. That changes which OPTIONS a load resolves,
1240        // not how a `CostNumber` is archived, so the byte arrays below are
1241        // untouched and only FIXTURE_VERSION moves.
1242        // v31 (#2160) is the FIRST bump in this list that genuinely moves an
1243        // archived layout: `Note` gained `tags` and `links`. The byte arrays
1244        // below are still valid, but for a different reason than v25/v30 --
1245        // not "no layout moved" but "the layout that moved is not this one".
1246        // They pin `CostNumber` discriminants and payload encodings, which
1247        // `Note` does not participate in. A future bump that touches
1248        // `CostNumber` itself has to regenerate them.
1249        // v32 is a v25/v30 again: a blank line before a `note` or `document`
1250        // stops eating its tags. That changes which TAGS a parse yields, not
1251        // how a `CostNumber` is archived, so the byte arrays below hold.
1252        // v33 (#2191) changes which balance and price VALUES a parse yields,
1253        // and which of them error at all. `CostNumber`'s discriminants and
1254        // payload encodings are untouched, so the arrays below still pin them.
1255        // v34 (#2194) changes which ERRORS a parse yields, not which
1256        // directives -- the tagged `query` is still emitted, alongside a new
1257        // diagnostic. Either way it is not how a `CostNumber` is archived, so
1258        // the byte arrays below still hold.
1259        // v35 (#2193) is the same shape for balance tolerances: new errors,
1260        // same archived `CostNumber` encoding.
1261        const FIXTURE_VERSION: u32 = 35;
1262        assert_eq!(
1263            CACHE_VERSION, FIXTURE_VERSION,
1264            "CACHE_VERSION advanced past the fixture version; regenerate \
1265             the byte fixtures in this test and update FIXTURE_VERSION, \
1266             or remove the tripwire if v{CACHE_VERSION}'s CostNumber \
1267             encoding is byte-identical to the fixtures.",
1268        );
1269
1270        let cases: &[(&str, CostNumber, &[u8])] = &[
1271            (
1272                "PerUnit { value: 150 }",
1273                CostNumber::PerUnit { value: dec!(150) },
1274                &[
1275                    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,
1276                    0, 0, 0, 0, 0, 0, 0,
1277                ],
1278            ),
1279            (
1280                "Compound { per_unit: 5, total: 10 }",
1281                CostNumber::Compound {
1282                    per_unit: dec!(5),
1283                    total: dec!(10),
1284                },
1285                &[
1286                    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,
1287                    0, 0, 0, 0, 0, 0, 0,
1288                ],
1289            ),
1290            (
1291                "Total { value: 1500 }",
1292                CostNumber::Total { value: dec!(1500) },
1293                &[
1294                    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,
1295                    0, 0, 0, 0, 0, 0, 0,
1296                ],
1297            ),
1298            (
1299                "PerUnitFromTotal { per_unit: 150, total: 300 }",
1300                CostNumber::PerUnitFromTotal(BookedCost::new(dec!(150), dec!(300), dec!(2))),
1301                &[
1302                    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,
1303                    0, 0, 0, 0, 0, 0, 0, 0,
1304                ],
1305            ),
1306        ];
1307        let mut mismatches = Vec::new();
1308        for (name, cn, expected) in cases {
1309            let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(cn).unwrap();
1310            if bytes.as_ref() != *expected {
1311                mismatches.push(format!("  `{name}` → {:?}", bytes.as_ref()));
1312            }
1313        }
1314        assert!(
1315            mismatches.is_empty(),
1316            "rkyv layout drifted from v8 fixtures — bump CACHE_VERSION and \
1317             update the fixtures in this test if intentional. Actual bytes:\n{}",
1318            mismatches.join("\n"),
1319        );
1320    }
1321
1322    /// Layout-hash tripwire for [`rustledger_core::MetaValue`] — generalizes the
1323    /// `CostNumber` frozen-byte fixtures above to the metadata value type the
1324    /// cache also archives.
1325    ///
1326    /// The `CostNumber` fixtures only catch drift in cost numbers. A `MetaValue`
1327    /// variant reorder, or an rkyv encoding shift in how `InternedStr` / `String`
1328    /// / `Decimal` / `Amount` pack, changes the on-disk metadata bytes while
1329    /// `CostNumber` stays byte-identical — and `MetaValue::Int` (v11) was
1330    /// previously guarded only by a code comment, not a test. This hashes the
1331    /// archived bytes of one of every `MetaValue` variant (declaration order,
1332    /// length-prefixed) and pins the digest. Any archived-layout drift trips this,
1333    /// forcing the developer to bump `CACHE_VERSION` (so stale on-disk caches
1334    /// short-circuit at the header check) and regenerate the hash.
1335    ///
1336    /// Little-endian only, like the `CostNumber` fixtures — `rkyv::to_bytes` uses
1337    /// native endianness, and `CACHE_VERSION` guards same-machine reads.
1338    #[cfg(target_endian = "little")]
1339    #[test]
1340    fn meta_value_archived_layout_hash_matches() {
1341        use rustledger_core::{Account, Currency, Link, MetaValue, Tag};
1342
1343        // Tripwire: regenerating the hash without bumping CACHE_VERSION leaves
1344        // users with rotten metadata caches.
1345        // v13 (#1700) added a CostNumber variant; MetaValue's archived
1346        // layout is untouched, so per the tripwire contract only the
1347        // fixture version moves.
1348        // v15 (#1884) is a parser-diagnostics change with no layout impact —
1349        // same reasoning, and the hash assertion below is what verifies it.
1350        // v18 (#1939) evaluates arithmetic in a cost spec; `MetaValue` is not
1351        // involved at all, so the hash below must be unchanged — and is.
1352        // v19 (#1944) DOES touch metadata, but only which VALUE a given source
1353        // text produces (`2 * 3` -> Int(6) rather than Int(2)). The variants
1354        // and their archived encodings are untouched, so the hash below must
1355        // still match — and the assertion, not this comment, is what proves it.
1356        // v20 (#1930) is an account-name lexer change; `MetaValue` is
1357        // untouched and the hash below must still match.
1358        // v28 is the negative-zero parse rule: a literal `-0.00` now archives
1359        // an UNSIGNED zero. Like v19 that changes which VALUE a source text
1360        // produces, not the variants or their encodings, so the hash below
1361        // must still match — and the assertion, not this comment, proves it.
1362        // v30 (#2151) changes which options an included file contributes,
1363        // not how a `MetaValue` is archived; the hash below is unchanged.
1364        // v31 (#2160) adds `tags` and `links` to `Note`. That moves `Note`'s
1365        // archived layout, not `MetaValue`'s -- the two new fields are
1366        // `Vec<Tag>` and `Vec<Link>`, neither of which is a `MetaValue` -- so
1367        // the hash below is unchanged and the assertion proves it.
1368        // v32 keeps a note's/document's tags across a preceding blank line.
1369        // Tags and links are not `MetaValue`s, so the hash below is unchanged.
1370        // v33 (#2191) moves balance and price amounts, which are `Decimal`s on
1371        // the directive, not `MetaValue`s; the hash below is unchanged.
1372        // v34 (#2194) adds a syntax error to a tagged `query` and keeps the
1373        // directive; no `MetaValue` is involved either way, so the hash below
1374        // is unchanged.
1375        // v35 (#2193) likewise adds errors on a balance tolerance and touches
1376        // no `MetaValue`; the hash below is unchanged.
1377        const FIXTURE_VERSION: u32 = 35;
1378        const META_VALUE_LAYOUT_HASH: &str =
1379            "43e3c258fe376cede6a6c2c975100bcf67ddda0ab84b21566b123c01e0a54b25";
1380        assert_eq!(
1381            CACHE_VERSION, FIXTURE_VERSION,
1382            "CACHE_VERSION advanced past the MetaValue layout-hash fixture; if the \
1383             MetaValue archived layout changed, bump CACHE_VERSION and regenerate \
1384             META_VALUE_LAYOUT_HASH below in the same commit, else just bump \
1385             FIXTURE_VERSION.",
1386        );
1387
1388        // One value of every variant in declaration order. Each is archived alone
1389        // (no metadata map), so the bytes are deterministic.
1390        let variants: &[MetaValue] = &[
1391            MetaValue::String("USD".to_string()),
1392            MetaValue::Account(Account::from("Assets:Bank")),
1393            MetaValue::Currency(Currency::from("USD")),
1394            MetaValue::Tag(Tag::from("t")),
1395            MetaValue::Link(Link::from("t")),
1396            MetaValue::Date(rustledger_core::naive_date(2024, 1, 15).unwrap()),
1397            MetaValue::Number(dec!(42)),
1398            MetaValue::Bool(true),
1399            MetaValue::Amount(Amount::new(dec!(10), "USD")),
1400            MetaValue::None,
1401            MetaValue::Int(42),
1402        ];
1403
1404        let mut hasher = Hasher::new();
1405        for mv in variants {
1406            let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(mv).unwrap();
1407            // Length-prefix so a byte moving across a variant boundary can't be
1408            // masked by a compensating change in the neighbor.
1409            hasher.update(&(bytes.len() as u64).to_le_bytes());
1410            hasher.update(&bytes);
1411        }
1412        let digest = hasher.finalize().to_hex();
1413
1414        assert_eq!(
1415            digest.as_str(),
1416            META_VALUE_LAYOUT_HASH,
1417            "MetaValue archived layout changed. If intentional, bump CACHE_VERSION \
1418             and set META_VALUE_LAYOUT_HASH to: {digest}",
1419        );
1420    }
1421
1422    #[test]
1423    fn test_reintern_directives_deduplication() {
1424        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1425
1426        // Create multiple transactions with the same account
1427        let mut directives = vec![];
1428        for i in 0..5 {
1429            let txn = Transaction::new(date, format!("Txn {i}"))
1430                .with_synthesized_posting(Posting::new(
1431                    "Expenses:Food",
1432                    Amount::new(dec!(10.00), "USD"),
1433                ))
1434                .with_synthesized_posting(Posting::auto("Assets:Checking"));
1435            directives.push(Spanned::new(Directive::Transaction(txn), Span::new(0, 50)));
1436        }
1437
1438        // Re-intern should deduplicate the repeated account names and currencies
1439        let dedup_count = reintern_directives(&mut directives);
1440
1441        // We should have deduplicated:
1442        // - "Expenses:Food" appears 5 times but only first is new (4 dedup)
1443        // - "USD" appears 5 times but only first is new (4 dedup)
1444        // - "Assets:Checking" appears 5 times but only first is new (4 dedup)
1445        // Total: 12 deduplications
1446        assert_eq!(dedup_count, 12);
1447    }
1448
1449    /// The property `load_result_cached` relies on when it skips
1450    /// `reintern_directives`: deserializing under an `InternScope` leaves
1451    /// equal strings sharing one `Arc`, which is exactly what that pass
1452    /// exists to guarantee.
1453    ///
1454    /// Asserts the NEGATIVE half first. Without the scope every occurrence
1455    /// gets its own `Arc`, so if the scope ever stopped working this test
1456    /// would still be checking something real rather than passing because
1457    /// `ptr_eq` happened to hold for another reason.
1458    #[test]
1459    fn cache_hit_directives_share_one_arc_per_distinct_string() {
1460        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1461        let mut directives = vec![];
1462        for _ in 0..5 {
1463            // Every field the same, so each of the four categories below has
1464            // five occurrences of one string to share.
1465            let txn = Transaction::new(date, "SAME-NARRATION")
1466                .with_payee("SAME-PAYEE")
1467                .with_synthesized_posting(Posting::new(
1468                    "Expenses:Food",
1469                    Amount::new(dec!(10.00), "USD"),
1470                ))
1471                .with_synthesized_posting(Posting::auto("Assets:Checking"));
1472            directives.push(Spanned::new(Directive::Transaction(txn), Span::new(0, 50)));
1473        }
1474
1475        let bytes =
1476            rkyv::to_bytes::<rkyv::rancor::Error>(&directives).expect("directives serialize");
1477
1478        // All four categories of `InternedStr` a transaction carries, because
1479        // `reintern_directives` covers all of them and skipping it is only
1480        // sound if the scope does too. `account` reaches `AsInternedStr`
1481        // through the `Account` newtype and `currency` through `Amount`,
1482        // neither of which names the wrapper at the field, so covering one
1483        // does not imply covering the others.
1484        let pairs = |ds: &[Spanned<Directive>]| {
1485            let pick = |d: &Spanned<Directive>| match &d.value {
1486                Directive::Transaction(t) => {
1487                    let currency = match &t.postings[0].units {
1488                        Some(IncompleteAmount::Complete(a)) => a.currency.clone(),
1489                        other => panic!("expected complete units, got {other:?}"),
1490                    };
1491                    (
1492                        t.postings[0].account.clone(),
1493                        currency,
1494                        t.payee.clone().expect("payee"),
1495                        t.narration.clone(),
1496                    )
1497                }
1498                other => panic!("expected a transaction, got {other:?}"),
1499            };
1500            (pick(&ds[0]), pick(&ds[4]))
1501        };
1502
1503        let plain: Vec<Spanned<Directive>> =
1504            rkyv::from_bytes::<Vec<Spanned<Directive>>, rkyv::rancor::Error>(&bytes)
1505                .expect("deserialize without a scope");
1506        let (x, y) = pairs(&plain);
1507        assert_eq!(x.0.as_str(), y.0.as_str());
1508        assert!(
1509            !x.0.ptr_eq(&y.0) && !x.1.ptr_eq(&y.1) && !x.2.ptr_eq(&y.2) && !x.3.ptr_eq(&y.3),
1510            "without an InternScope each occurrence should get its own Arc - \
1511             if this now holds, the positive assertions below prove nothing"
1512        );
1513
1514        let scoped: Vec<Spanned<Directive>> = {
1515            let _intern = rustledger_core::intern::InternScope::new();
1516            rkyv::from_bytes::<Vec<Spanned<Directive>>, rkyv::rancor::Error>(&bytes)
1517                .expect("deserialize under a scope")
1518        };
1519        let (x, y) = pairs(&scoped);
1520        for (label, shared) in [
1521            ("account", x.0.ptr_eq(&y.0)),
1522            ("currency", x.1.ptr_eq(&y.1)),
1523            ("payee", x.2.ptr_eq(&y.2)),
1524            ("narration", x.3.ptr_eq(&y.3)),
1525        ] {
1526            assert!(
1527                shared,
1528                "under an InternScope {label} must share one Arc, which is \
1529                 what lets the cache-hit path skip reintern_directives"
1530            );
1531        }
1532    }
1533
1534    /// Deserialize `bytes` (optionally under a scope) and return the account
1535    /// of the first transaction. Interning only happens inside
1536    /// `AsInternedStr::deserialize_with`, so a scope test that builds an
1537    /// `InternedStr` directly proves nothing — `InternedStr::new` does not
1538    /// consult the scope at all.
1539    fn first_account(bytes: &[u8]) -> rustledger_core::Account {
1540        let ds = rkyv::from_bytes::<Vec<Spanned<Directive>>, rkyv::rancor::Error>(bytes)
1541            .expect("deserialize");
1542        match &ds[0].value {
1543            Directive::Transaction(t) => t.postings[0].account.clone(),
1544            other => panic!("expected a transaction, got {other:?}"),
1545        }
1546    }
1547
1548    /// The table must not outlive its guard, or a long-running host would
1549    /// accumulate every string it ever deserialized.
1550    #[test]
1551    fn the_intern_scope_stops_interning_once_the_guard_drops() {
1552        let bytes = one_txn_archive();
1553        let inside = {
1554            let _intern = rustledger_core::intern::InternScope::new();
1555            let a = first_account(&bytes);
1556            // Same scope, second deserialization: shares.
1557            assert!(first_account(&bytes).ptr_eq(&a));
1558            a
1559        };
1560        // The guard has dropped, so a fresh deserialization cannot reach the
1561        // table that produced `inside`.
1562        let after = first_account(&bytes);
1563        assert_eq!(inside.as_str(), after.as_str());
1564        assert!(
1565            !inside.ptr_eq(&after),
1566            "the table must be gone once the guard drops"
1567        );
1568    }
1569
1570    /// An inner scope must not pull the table out from under an outer one
1571    /// when it drops. `InternScope::new` returns a guard either way, so
1572    /// without the `installed` flag the inner `Drop` would clear the table
1573    /// and silently stop interning for the rest of the outer scope — which
1574    /// no assertion about a single scope would notice.
1575    #[test]
1576    fn a_nested_intern_scope_leaves_the_outer_one_interning() {
1577        let bytes = one_txn_archive();
1578        let outer = rustledger_core::intern::InternScope::new();
1579        let first = first_account(&bytes);
1580        {
1581            let _inner = rustledger_core::intern::InternScope::new();
1582            assert!(
1583                first_account(&bytes).ptr_eq(&first),
1584                "the inner scope should adopt the outer table, not replace it"
1585            );
1586        }
1587        assert!(
1588            first_account(&bytes).ptr_eq(&first),
1589            "the outer scope must still be interning after the inner guard drops"
1590        );
1591        drop(outer);
1592        assert!(!first_account(&bytes).ptr_eq(&first));
1593    }
1594
1595    /// One archived transaction, for the scope tests above.
1596    fn one_txn_archive() -> rkyv::util::AlignedVec {
1597        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1598        let txn = Transaction::new(date, "N")
1599            .with_synthesized_posting(Posting::new(
1600                "Expenses:Food",
1601                Amount::new(dec!(10.00), "USD"),
1602            ))
1603            .with_synthesized_posting(Posting::auto("Assets:Checking"));
1604        let ds = vec![Spanned::new(Directive::Transaction(txn), Span::new(0, 50))];
1605        rkyv::to_bytes::<rkyv::rancor::Error>(&ds).expect("serialize")
1606    }
1607
1608    #[test]
1609    fn test_cached_options_roundtrip() {
1610        let mut opts = Options::new();
1611        opts.title = Some("Test Ledger".to_string());
1612        opts.operating_currency = vec!["USD".to_string(), "EUR".to_string()];
1613        opts.render_commas = true;
1614
1615        let cached = CachedOptions::from(&opts);
1616        let restored: Options = cached.into();
1617
1618        assert_eq!(restored.title, Some("Test Ledger".to_string()));
1619        assert_eq!(restored.operating_currency, vec!["USD", "EUR"]);
1620        assert!(restored.render_commas);
1621    }
1622
1623    /// Structural guard (fitness function): populate EVERY non-transient
1624    /// `Options` field with a non-default value, round-trip through
1625    /// `CachedOptions`, and assert nothing was dropped. A new `Options` field
1626    /// that `CachedOptions` forgets to carry fails here — the bug class that
1627    /// silently dropped `display_precision` / `use_precise_interpolation` /
1628    /// `plugin_processing_mode` (and `set_options` before #1340).
1629    ///
1630    /// `warnings` is intentionally transient (re-derived, not cached), so it is
1631    /// left default on both sides. **When you add a field to `Options`, set it
1632    /// here too.**
1633    #[test]
1634    fn cached_options_field_parity() {
1635        use rust_decimal_macros::dec;
1636
1637        let mut opts = Options::new();
1638        opts.title = Some("T".into());
1639        opts.filename = Some("f.beancount".into());
1640        opts.operating_currency = vec!["USD".into(), "EUR".into()];
1641        opts.name_assets = "A".into();
1642        opts.name_liabilities = "L".into();
1643        opts.name_equity = "Q".into();
1644        opts.name_income = "I".into();
1645        opts.name_expenses = "X".into();
1646        opts.account_rounding = Some("Equity:Round".into());
1647        opts.account_previous_balances = "Opening".into();
1648        opts.account_previous_earnings = "Earn".into();
1649        opts.account_previous_conversions = "Conv".into();
1650        opts.account_current_earnings = "CurEarn".into();
1651        opts.account_current_conversions = Some("CurConv".into());
1652        opts.account_unrealized_gains = Some("Unreal".into());
1653        opts.conversion_currency = Some("NOTHING".into());
1654        opts.inferred_tolerance_default =
1655            std::iter::once(("USD".to_string(), dec!(0.005))).collect();
1656        opts.inferred_tolerance_multiplier = dec!(1.5);
1657        opts.infer_tolerance_from_cost = true;
1658        opts.use_legacy_fixed_tolerances = true;
1659        opts.experiment_explicit_tolerances = true;
1660        opts.use_precise_interpolation = true;
1661        opts.booking_method = "FIFO".into();
1662        opts.render_commas = true;
1663        opts.display_precision = [("USD".to_string(), 4u32), ("JPY".to_string(), 0)]
1664            .into_iter()
1665            .collect();
1666        opts.allow_pipe_separator = true;
1667        opts.long_string_maxlines = 99;
1668        opts.documents = vec!["docs".into()];
1669        opts.plugin_processing_mode = "raw".into();
1670        opts.custom = std::iter::once(("k".to_string(), "v".to_string())).collect();
1671        opts.set_options = std::iter::once("booking_method".to_string()).collect();
1672        // `warnings` left default (transient — not cached).
1673
1674        let restored: Options = CachedOptions::from(&opts).into();
1675        assert_eq!(
1676            restored, opts,
1677            "a CachedOptions field was dropped on the cache round-trip"
1678        );
1679    }
1680
1681    /// Regression for #1340: `set_options` must survive the cache
1682    /// round-trip. It gates `resolve_effective_booking_method`, so
1683    /// dropping it makes a cache hit re-book FIFO/LIFO ledgers as
1684    /// STRICT (the file-level `option "booking_method"` is ignored).
1685    #[test]
1686    fn test_cached_options_preserves_set_options_for_booking_method() {
1687        let mut opts = Options::new();
1688        // `set()` is what a parsed `option "booking_method" "FIFO"`
1689        // calls — it records both the value AND the set-membership.
1690        opts.set("booking_method", "FIFO");
1691        assert!(opts.set_options.contains("booking_method"));
1692
1693        let cached = CachedOptions::from(&opts);
1694        let restored: Options = cached.into();
1695
1696        assert_eq!(restored.booking_method, "FIFO");
1697        assert!(
1698            restored.set_options.contains("booking_method"),
1699            "set_options dropped across cache round-trip — booking method \
1700             resolution would fall back to the STRICT default on a cache hit"
1701        );
1702    }
1703
1704    #[test]
1705    fn test_cache_entry_file_paths() {
1706        let entry = CacheEntry {
1707            directives: vec![],
1708            options: CachedOptions::from(&Options::new()),
1709            plugins: vec![],
1710            files: vec![
1711                "/path/to/ledger.beancount".to_string(),
1712                "/path/to/include.beancount".to_string(),
1713            ],
1714        };
1715
1716        let paths = entry.file_paths();
1717        assert_eq!(paths.len(), 2);
1718        assert_eq!(paths[0], PathBuf::from("/path/to/ledger.beancount"));
1719        assert_eq!(paths[1], PathBuf::from("/path/to/include.beancount"));
1720    }
1721
1722    #[test]
1723    fn test_reintern_balance_directive() {
1724        use rustledger_core::Balance;
1725
1726        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1727        let balance = Balance::new(date, "Assets:Checking", Amount::new(dec!(1000.00), "USD"));
1728
1729        let mut directives = vec![
1730            Spanned::new(Directive::Balance(balance.clone()), Span::new(0, 50)),
1731            Spanned::new(Directive::Balance(balance), Span::new(51, 100)),
1732        ];
1733
1734        let dedup_count = reintern_directives(&mut directives);
1735        // Second occurrence of "Assets:Checking" and "USD" should be deduplicated
1736        assert_eq!(dedup_count, 2);
1737    }
1738
1739    #[test]
1740    fn test_reintern_open_close_directives() {
1741        use rustledger_core::{Close, Open};
1742
1743        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1744        let open = Open::new(date, "Assets:Checking");
1745        let close = Close::new(date, "Assets:Checking");
1746
1747        let mut directives = vec![
1748            Spanned::new(Directive::Open(open), Span::new(0, 50)),
1749            Spanned::new(Directive::Close(close), Span::new(51, 100)),
1750        ];
1751
1752        let dedup_count = reintern_directives(&mut directives);
1753        // Second "Assets:Checking" should be deduplicated
1754        assert_eq!(dedup_count, 1);
1755    }
1756}