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}