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