Skip to main content

rustledger_loader/
cache.rs

1//! Binary cache for parsed ledgers.
2//!
3//! This module provides a caching layer that can dramatically speed up
4//! subsequent loads of unchanged beancount files by serializing the parsed
5//! directives to a binary format using rkyv.
6//!
7//! # How it works
8//!
9//! 1. When loading a file, compute a hash of all source files
10//! 2. Check if a cache file exists with a matching hash
11//! 3. If yes, deserialize and return immediately (typically <1ms)
12//! 4. If no, parse normally, serialize to cache, and return
13//!
14//! # Cache location
15//!
16//! By default, cache files are stored alongside the main ledger as a hidden
17//! dotfile: `ledger.beancount` → `.ledger.beancount.cache`. This matches Python
18//! beancount's `.{filename}.picklecache` convention.
19//!
20//! Two environment variables control the location, both compatible with
21//! Python beancount and honored at the loader level (so any consumer of
22//! [`load_cache_entry`] / [`save_cache_entry`] gets the kill switch for free):
23//!
24//! - `BEANCOUNT_DISABLE_LOAD_CACHE`: when set (even to an empty value),
25//!   [`load_cache_entry`] returns `None` and [`save_cache_entry`] is a no-op.
26//! - `BEANCOUNT_LOAD_CACHE_FILENAME`: a path pattern that may contain
27//!   `{filename}` (replaced with the source basename). Relative paths resolve
28//!   against the source directory; absolute paths are used as-is. If the
29//!   target directory doesn't exist, [`save_cache_entry`] creates it.
30
31use crate::Options;
32use blake3::Hasher;
33use rust_decimal::Decimal;
34use rustledger_core::Directive;
35use rustledger_parser::Spanned;
36use std::fs;
37use std::io::{Read, Write};
38use std::path::{Path, PathBuf};
39use std::str::FromStr;
40
41/// Cached plugin information.
42#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
43pub struct CachedPlugin {
44    /// Plugin module name.
45    pub name: String,
46    /// Optional configuration string.
47    pub config: Option<String>,
48    /// Whether the `python:` prefix was used to force Python execution.
49    pub force_python: bool,
50}
51
52/// Cached options - a serializable subset of Options.
53///
54/// Excludes transient parsing-time fields like `warnings`, but DOES
55/// persist `set_options`: it is load-bearing downstream, because
56/// `resolve_effective_booking_method` gates on
57/// `set_options.contains("booking_method")` to decide whether the
58/// file-level `option "booking_method"` wins over the API default.
59/// Dropping it across the cache round-trip silently re-books FIFO/LIFO
60/// ledgers as STRICT on a cache hit (#1340).
61/// These fields mirror the Options struct and inherit their meaning.
62#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
63#[allow(missing_docs)]
64pub struct CachedOptions {
65    pub title: Option<String>,
66    pub filename: Option<String>,
67    pub operating_currency: Vec<String>,
68    pub name_assets: String,
69    pub name_liabilities: String,
70    pub name_equity: String,
71    pub name_income: String,
72    pub name_expenses: String,
73    pub account_rounding: Option<String>,
74    pub account_previous_balances: String,
75    pub account_previous_earnings: String,
76    pub account_previous_conversions: String,
77    pub account_current_earnings: String,
78    pub account_current_conversions: Option<String>,
79    pub account_unrealized_gains: Option<String>,
80    pub conversion_currency: Option<String>,
81    /// Stored as (currency, `tolerance_string`) pairs since Decimal needs special handling
82    pub inferred_tolerance_default: Vec<(String, String)>,
83    pub inferred_tolerance_multiplier: String,
84    pub infer_tolerance_from_cost: bool,
85    pub use_legacy_fixed_tolerances: bool,
86    pub experiment_explicit_tolerances: bool,
87    pub booking_method: String,
88    pub render_commas: bool,
89    pub allow_pipe_separator: bool,
90    pub long_string_maxlines: u32,
91    pub documents: Vec<String>,
92    pub custom: Vec<(String, String)>,
93    /// Names of options the source explicitly set (e.g.
94    /// `"booking_method"`). Restored so downstream resolution that
95    /// distinguishes "file set this" from "inherited default" behaves
96    /// identically on a cache hit. See the struct-level note (#1340).
97    pub set_options: Vec<String>,
98}
99
100impl From<&Options> for CachedOptions {
101    fn from(opts: &Options) -> Self {
102        Self {
103            title: opts.title.clone(),
104            filename: opts.filename.clone(),
105            operating_currency: opts.operating_currency.clone(),
106            name_assets: opts.name_assets.clone(),
107            name_liabilities: opts.name_liabilities.clone(),
108            name_equity: opts.name_equity.clone(),
109            name_income: opts.name_income.clone(),
110            name_expenses: opts.name_expenses.clone(),
111            account_rounding: opts.account_rounding.clone(),
112            account_previous_balances: opts.account_previous_balances.clone(),
113            account_previous_earnings: opts.account_previous_earnings.clone(),
114            account_previous_conversions: opts.account_previous_conversions.clone(),
115            account_current_earnings: opts.account_current_earnings.clone(),
116            account_current_conversions: opts.account_current_conversions.clone(),
117            account_unrealized_gains: opts.account_unrealized_gains.clone(),
118            conversion_currency: opts.conversion_currency.clone(),
119            inferred_tolerance_default: opts
120                .inferred_tolerance_default
121                .iter()
122                .map(|(k, v)| (k.clone(), v.to_string()))
123                .collect(),
124            inferred_tolerance_multiplier: opts.inferred_tolerance_multiplier.to_string(),
125            infer_tolerance_from_cost: opts.infer_tolerance_from_cost,
126            use_legacy_fixed_tolerances: opts.use_legacy_fixed_tolerances,
127            experiment_explicit_tolerances: opts.experiment_explicit_tolerances,
128            booking_method: opts.booking_method.clone(),
129            render_commas: opts.render_commas,
130            allow_pipe_separator: opts.allow_pipe_separator,
131            long_string_maxlines: opts.long_string_maxlines,
132            documents: opts.documents.clone(),
133            custom: opts
134                .custom
135                .iter()
136                .map(|(k, v)| (k.clone(), v.clone()))
137                .collect(),
138            set_options: opts.set_options.iter().cloned().collect(),
139        }
140    }
141}
142
143impl From<CachedOptions> for Options {
144    fn from(cached: CachedOptions) -> Self {
145        let mut opts = Self::new();
146        opts.title = cached.title;
147        opts.filename = cached.filename;
148        opts.operating_currency = cached.operating_currency;
149        opts.name_assets = cached.name_assets;
150        opts.name_liabilities = cached.name_liabilities;
151        opts.name_equity = cached.name_equity;
152        opts.name_income = cached.name_income;
153        opts.name_expenses = cached.name_expenses;
154        opts.account_rounding = cached.account_rounding;
155        opts.account_previous_balances = cached.account_previous_balances;
156        opts.account_previous_earnings = cached.account_previous_earnings;
157        opts.account_previous_conversions = cached.account_previous_conversions;
158        opts.account_current_earnings = cached.account_current_earnings;
159        opts.account_current_conversions = cached.account_current_conversions;
160        opts.account_unrealized_gains = cached.account_unrealized_gains;
161        opts.conversion_currency = cached.conversion_currency;
162        opts.inferred_tolerance_default = cached
163            .inferred_tolerance_default
164            .into_iter()
165            .filter_map(|(k, v)| Decimal::from_str(&v).ok().map(|d| (k, d)))
166            .collect();
167        opts.inferred_tolerance_multiplier =
168            Decimal::from_str(&cached.inferred_tolerance_multiplier)
169                .unwrap_or_else(|_| Decimal::new(5, 1));
170        opts.infer_tolerance_from_cost = cached.infer_tolerance_from_cost;
171        opts.use_legacy_fixed_tolerances = cached.use_legacy_fixed_tolerances;
172        opts.experiment_explicit_tolerances = cached.experiment_explicit_tolerances;
173        opts.booking_method = cached.booking_method;
174        opts.render_commas = cached.render_commas;
175        opts.allow_pipe_separator = cached.allow_pipe_separator;
176        opts.long_string_maxlines = cached.long_string_maxlines;
177        opts.documents = cached.documents;
178        opts.custom = cached.custom.into_iter().collect();
179        opts.set_options = cached.set_options.into_iter().collect();
180        opts
181    }
182}
183
184/// Complete cache entry containing all data needed to restore a `LoadResult`.
185#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
186pub struct CacheEntry {
187    /// All parsed directives.
188    pub directives: Vec<Spanned<Directive>>,
189    /// Parsed options.
190    pub options: CachedOptions,
191    /// Plugin declarations.
192    pub plugins: Vec<CachedPlugin>,
193    /// All files that were loaded (as strings, for serialization).
194    pub files: Vec<String>,
195}
196
197impl CacheEntry {
198    /// Get files as `PathBuf` references.
199    pub fn file_paths(&self) -> Vec<PathBuf> {
200        self.files.iter().map(PathBuf::from).collect()
201    }
202
203    /// Reconstruct a [`LoadResult`](crate::LoadResult) equivalent to a
204    /// fresh parse of the cached source.
205    ///
206    /// Re-reads each cached source file for the source map (so error
207    /// reporting still has text), converts the cached plugin
208    /// declarations back (their span / `file_id` are not meaningful
209    /// from cache), and — crucially — rebuilds the display context from
210    /// the cached directives + options via the same inference a fresh
211    /// load uses, so a cache-hit `LoadResult` formats numbers
212    /// identically to an uncached one. Reconstructing it as an empty
213    /// `DisplayContext` (as the per-command CLI code used to) would
214    /// silently change per-currency display precision for any consumer
215    /// that reads it.
216    ///
217    /// `errors` is empty by construction: the cache is only written for
218    /// error-free, warning-free loads.
219    ///
220    /// Strings are NOT re-interned here; a caller that wants the memory
221    /// dedup should call [`crate::reintern_directives`] on
222    /// `self.directives` first (it needs `&mut`).
223    #[must_use]
224    pub fn into_load_result(self) -> crate::LoadResult {
225        let mut source_map = crate::SourceMap::new();
226        for path in self.file_paths() {
227            // Read bytes + lossy UTF-8 to match `DiskFileSystem::read`
228            // (the uncached loader path). `read_to_string` would error
229            // and silently skip a non-UTF8 source file, leaving the
230            // cache-hit source map missing text the uncached run has -
231            // an error-reporting parity gap.
232            if let Ok(bytes) = fs::read(&path) {
233                let content = String::from_utf8_lossy(&bytes).into_owned();
234                source_map.add_file(path, content.into());
235            }
236        }
237
238        let plugins: Vec<crate::Plugin> = self
239            .plugins
240            .iter()
241            .map(|p| crate::Plugin {
242                name: p.name.clone(),
243                config: p.config.clone(),
244                span: rustledger_parser::Span::ZERO,
245                file_id: 0,
246                force_python: p.force_python,
247            })
248            .collect();
249
250        let options: Options = self.options.into();
251        let display_context = crate::build_display_context(&self.directives, &options);
252
253        crate::LoadResult {
254            directives: self.directives,
255            options,
256            plugins,
257            source_map,
258            errors: Vec::new(),
259            display_context,
260        }
261    }
262}
263
264/// Magic bytes to identify cache files.
265const CACHE_MAGIC: &[u8; 8] = b"RLEDGER\0";
266
267/// Cache version - increment when format changes.
268/// v1: Initial release with string-based Decimal/NaiveDate
269/// v2: Binary Decimal (16 bytes) and `NaiveDate` (i32 days)
270/// v3: Fixed account type defaults in `CachedOptions`
271/// v4: Hash algorithm switched from SHA-256 to BLAKE3 — same 32-byte
272///     output so the header layout is unchanged, but old hashes won't
273///     match new files. Bumping the version short-circuits stale
274///     caches at the header check instead of paying the rkyv
275///     deserialize cost only to fail the hash compare.
276/// v5: `Transaction.postings: Vec<Posting>` became
277///     `Vec<Spanned<Posting>>` (#1151). The inner posting bytes
278///     gained a `Span + file_id` per entry, so old cache files
279///     would rkyv-deserialize into the new type as junk. Header
280///     check forces a rebuild instead.
281/// v6: The #1163 newtype slices (#1169 `Currency`, #1171 `Account`,
282///     #1172 `Tag`, #1173 `Link`, #1174 `MetaValue`) swapped variant
283///     payload types from `InternedStr`/`String` to typed newtypes.
284///     The archived layout coincidentally matches `AsInternedStr`
285///     in most cases, but `MetaValue::{Account,Currency,Tag,Link}`
286///     and `Transaction.tags`/`links` (plus the parallel `Document`
287///     fields) changed their archive wrappers. Bumping the version
288///     forces regeneration so we don't risk rkyv reading old bytes
289///     into a structurally-different `ArchivedMetaValue`.
290/// v7: `PriceAnnotation` refactored from 6-variant enum to
291///     `{ kind: PriceKind, amount: Option<IncompleteAmount> }`
292///     (#1167). Old cache bytes for the enum's discriminant would
293///     deserialize as nonsense in the new struct layout.
294/// v8: `CostSpec.{number_per,number_total}: Option<Decimal>` collapsed
295///     into `CostSpec.number: Option<CostNumber>` where `CostNumber` is
296///     a 3-variant enum (`PerUnit`, `Total`, `PerUnitFromTotal`)
297///     (#1164). The archived layout is structurally different
298///     (Option<Decimal> + Option<Decimal> → Option<discriminant +
299///     payload>); reading v7 bytes into the v8 layout would produce
300///     garbage cost numbers. Bumping forces regeneration.
301///     Subsequent #1164 follow-up commits converted `CostNumber`'s
302///     variants from tuple form (`PerUnit(Decimal)`) to struct form
303///     (`PerUnit { value: Decimal }`) so serde could apply
304///     `tag = "kind"` for cross-boundary wire unification. The rkyv-
305///     archived layout for a single-field struct variant is byte-
306///     identical to the tuple variant (both pack `Archived<Decimal>`
307///     positionally) — verified against rkyv 0.8.16 — so this change
308///     does NOT require a separate version bump. If a future rkyv
309///     version changes that encoding, OR if `CostNumber` gains
310///     additional fields, bump to v10.
311/// v9: `CachedOptions` gained a `set_options: Vec<String>` field
312///     (#1340). It was previously dropped, so a cache hit lost the
313///     record of which options the file explicitly set — making
314///     `resolve_effective_booking_method` re-book FIFO/LIFO ledgers as
315///     STRICT. The new trailing field changes the archived layout, so
316///     old bytes must be regenerated.
317const CACHE_VERSION: u32 = 9;
318
319/// Cache header stored at the start of cache files.
320#[derive(Debug, Clone)]
321struct CacheHeader {
322    /// Magic bytes for identification.
323    magic: [u8; 8],
324    /// Cache format version.
325    version: u32,
326    /// BLAKE3 hash of source files (path + mtime + size).
327    hash: [u8; 32],
328    /// Length of the serialized data.
329    data_len: u64,
330}
331
332impl CacheHeader {
333    const SIZE: usize = 8 + 4 + 32 + 8;
334
335    fn to_bytes(&self) -> [u8; Self::SIZE] {
336        let mut buf = [0u8; Self::SIZE];
337        buf[0..8].copy_from_slice(&self.magic);
338        buf[8..12].copy_from_slice(&self.version.to_le_bytes());
339        buf[12..44].copy_from_slice(&self.hash);
340        buf[44..52].copy_from_slice(&self.data_len.to_le_bytes());
341        buf
342    }
343
344    fn from_bytes(bytes: &[u8]) -> Option<Self> {
345        if bytes.len() < Self::SIZE {
346            return None;
347        }
348
349        let mut magic = [0u8; 8];
350        magic.copy_from_slice(&bytes[0..8]);
351
352        let version = u32::from_le_bytes(bytes[8..12].try_into().ok()?);
353
354        let mut hash = [0u8; 32];
355        hash.copy_from_slice(&bytes[12..44]);
356
357        let data_len = u64::from_le_bytes(bytes[44..52].try_into().ok()?);
358
359        Some(Self {
360            magic,
361            version,
362            hash,
363            data_len,
364        })
365    }
366}
367
368/// Compute a hash of the given files and their modification times.
369///
370/// Files whose metadata cannot be read (e.g., deleted between load and cache)
371/// contribute only their path to the hash. This is intentional — the resulting
372/// hash mismatch will cause a cache miss on next load.
373fn compute_hash(files: &[&Path]) -> [u8; 32] {
374    let mut hasher = Hasher::new();
375
376    for file in files {
377        // Hash the file path
378        hasher.update(file.to_string_lossy().as_bytes());
379
380        // Hash the modification time (skip silently if inaccessible)
381        if let Ok(metadata) = fs::metadata(file) {
382            if let Ok(mtime) = metadata.modified()
383                && let Ok(duration) = mtime.duration_since(std::time::UNIX_EPOCH)
384            {
385                hasher.update(&duration.as_secs().to_le_bytes());
386                hasher.update(&duration.subsec_nanos().to_le_bytes());
387            }
388            // Hash the file size
389            hasher.update(&metadata.len().to_le_bytes());
390        }
391    }
392
393    *hasher.finalize().as_bytes()
394}
395
396/// Environment variable that overrides the default cache filename pattern.
397///
398/// The value is a path that may contain `{filename}` as a placeholder for the
399/// source file's basename. Relative paths are resolved against the source
400/// file's directory; absolute paths are used as-is. Mirrors Python beancount's
401/// `BEANCOUNT_LOAD_CACHE_FILENAME`.
402pub const CACHE_FILENAME_ENV: &str = "BEANCOUNT_LOAD_CACHE_FILENAME";
403
404/// Environment variable that disables the binary cache entirely when set.
405///
406/// Mirrors Python beancount's `BEANCOUNT_DISABLE_LOAD_CACHE`.
407pub const DISABLE_CACHE_ENV: &str = "BEANCOUNT_DISABLE_LOAD_CACHE";
408
409/// Returns the cache file path for a given source file.
410///
411/// Resolution order:
412/// 1. If `BEANCOUNT_LOAD_CACHE_FILENAME` is set, substitute `{filename}` with
413///    the source basename and resolve relative paths against the source dir.
414/// 2. Otherwise, default to a hidden dotfile alongside the source via
415///    [`default_cache_path`]: `path/to/main.beancount` →
416///    `path/to/.main.beancount.cache`.
417///
418/// The dotfile prefix matches Python beancount's `.{filename}.picklecache`
419/// convention, so the cache stays out of the way of `ls` and most file
420/// explorers without breaking from the established beancount ecosystem
421/// behavior. See issue #939.
422///
423/// This function reads process env. Tests that need a deterministic path
424/// regardless of the caller's environment should use [`default_cache_path`]
425/// directly.
426pub fn cache_path(source: &Path) -> PathBuf {
427    if let Ok(pattern) = std::env::var(CACHE_FILENAME_ENV)
428        && !pattern.is_empty()
429    {
430        return resolve_cache_pattern(source, &pattern);
431    }
432    default_cache_path(source)
433}
434
435/// Returns the default cache file path (no env-var lookup).
436///
437/// Use this when you need a path that is independent of process env, e.g.
438/// in tests that mustn't be perturbed by a developer's
439/// `BEANCOUNT_LOAD_CACHE_FILENAME`.
440#[must_use]
441pub fn default_cache_path(source: &Path) -> PathBuf {
442    let mut path = source.to_path_buf();
443    let name = path.file_name().map_or_else(
444        || ".ledger.cache".to_string(),
445        |n| format!(".{}.cache", n.to_string_lossy()),
446    );
447    path.set_file_name(name);
448    path
449}
450
451/// Resolve a `BEANCOUNT_LOAD_CACHE_FILENAME` pattern against a source path.
452///
453/// The `"{filename}"` token below is a literal user-facing substitution
454/// placeholder (matching Python beancount), not a `format!` argument — hence
455/// the explicit allow.
456#[allow(clippy::literal_string_with_formatting_args)]
457fn resolve_cache_pattern(source: &Path, pattern: &str) -> PathBuf {
458    let filename = source.file_name().map_or_else(
459        || "ledger".to_string(),
460        |n| n.to_string_lossy().into_owned(),
461    );
462    let resolved = pattern.replace("{filename}", &filename);
463    let p = PathBuf::from(&resolved);
464    if p.is_absolute() {
465        return p;
466    }
467    source.parent().map_or(p.clone(), |parent| parent.join(&p))
468}
469
470/// Returns the legacy (pre-#939) cache path: `<source>.cache` alongside source.
471///
472/// Used by `save_cache_entry` to opportunistically clean up stale cache files
473/// from earlier rustledger versions. Not part of the lookup path.
474fn legacy_cache_path(source: &Path) -> PathBuf {
475    let mut path = source.to_path_buf();
476    let name = path.file_name().map_or_else(
477        || "ledger.cache".to_string(),
478        |n| format!("{}.cache", n.to_string_lossy()),
479    );
480    path.set_file_name(name);
481    path
482}
483
484/// Returns true if `BEANCOUNT_DISABLE_LOAD_CACHE` is set in the environment.
485///
486/// Mere presence disables — value is ignored, including empty string. Matches
487/// Python beancount's `os.getenv("BEANCOUNT_DISABLE_LOAD_CACHE") is None`
488/// check.
489#[must_use]
490pub fn cache_disabled_by_env() -> bool {
491    std::env::var_os(DISABLE_CACHE_ENV).is_some()
492}
493
494/// Try to load a cache entry from disk.
495///
496/// Returns `Some(CacheEntry)` if cache is valid and file hashes match,
497/// `None` if cache is missing, invalid, outdated, or
498/// `BEANCOUNT_DISABLE_LOAD_CACHE` is set.
499pub fn load_cache_entry(main_file: &Path) -> Option<CacheEntry> {
500    if cache_disabled_by_env() {
501        return None;
502    }
503    let cache_file = cache_path(main_file);
504    let mut file = fs::File::open(&cache_file).ok()?;
505
506    // Read header
507    let mut header_bytes = [0u8; CacheHeader::SIZE];
508    file.read_exact(&mut header_bytes).ok()?;
509    let header = CacheHeader::from_bytes(&header_bytes)?;
510
511    // Validate magic and version
512    if header.magic != *CACHE_MAGIC {
513        return None;
514    }
515    if header.version != CACHE_VERSION {
516        return None;
517    }
518
519    // Read data
520    let mut data = vec![0u8; header.data_len as usize];
521    file.read_exact(&mut data).ok()?;
522
523    // Deserialize
524    let entry: CacheEntry = rkyv::from_bytes::<CacheEntry, rkyv::rancor::Error>(&data).ok()?;
525
526    // Validate hash against the files stored in the cache
527    let file_paths = entry.file_paths();
528    let file_refs: Vec<&Path> = file_paths.iter().map(PathBuf::as_path).collect();
529    let expected_hash = compute_hash(&file_refs);
530    if header.hash != expected_hash {
531        return None;
532    }
533
534    Some(entry)
535}
536
537/// Save a cache entry to disk.
538///
539/// No-op (returns Ok) when `BEANCOUNT_DISABLE_LOAD_CACHE` is set.
540pub fn save_cache_entry(main_file: &Path, entry: &CacheEntry) -> Result<(), std::io::Error> {
541    if cache_disabled_by_env() {
542        return Ok(());
543    }
544    let cache_file = cache_path(main_file);
545
546    // Compute hash from the files in the entry
547    let file_paths = entry.file_paths();
548    let file_refs: Vec<&Path> = file_paths.iter().map(PathBuf::as_path).collect();
549    let hash = compute_hash(&file_refs);
550
551    // Serialize
552    let data = rkyv::to_bytes::<rkyv::rancor::Error>(entry)
553        .map(|v| v.to_vec())
554        .map_err(|e| std::io::Error::other(e.to_string()))?;
555
556    // Write header + data
557    let header = CacheHeader {
558        magic: *CACHE_MAGIC,
559        version: CACHE_VERSION,
560        hash,
561        data_len: data.len() as u64,
562    };
563
564    // Custom BEANCOUNT_LOAD_CACHE_FILENAME patterns can point at a directory
565    // that doesn't exist yet (e.g. ~/.cache/rledger/foo.cache on a fresh
566    // install). Create the parent eagerly so caching isn't silently disabled.
567    if let Some(parent) = cache_file.parent()
568        && !parent.as_os_str().is_empty()
569    {
570        fs::create_dir_all(parent)?;
571    }
572
573    let mut file = fs::File::create(&cache_file)?;
574    file.write_all(&header.to_bytes())?;
575    file.write_all(&data)?;
576
577    // One-shot cleanup of pre-#939 visible cache files. Only attempt when the
578    // legacy path differs from the new path (i.e., we're not using a custom
579    // pattern that happens to land on the old name) and silently ignore
580    // failures — leaving the file is harmless, just untidy.
581    let legacy = legacy_cache_path(main_file);
582    if legacy != cache_file && legacy.exists() {
583        let _ = fs::remove_file(&legacy);
584    }
585
586    Ok(())
587}
588
589/// Serialize directives to bytes using rkyv (for benchmarking).
590#[cfg(test)]
591fn serialize_directives(directives: &Vec<Spanned<Directive>>) -> Result<Vec<u8>, std::io::Error> {
592    rkyv::to_bytes::<rkyv::rancor::Error>(directives)
593        .map(|v| v.to_vec())
594        .map_err(|e| std::io::Error::other(e.to_string()))
595}
596
597/// Deserialize directives from bytes using rkyv (for benchmarking).
598#[cfg(test)]
599fn deserialize_directives(data: &[u8]) -> Option<Vec<Spanned<Directive>>> {
600    rkyv::from_bytes::<Vec<Spanned<Directive>>, rkyv::rancor::Error>(data).ok()
601}
602
603/// Invalidate the cache for a file.
604///
605/// Removes both the current cache file and any legacy pre-#939
606/// `<file>.cache` sidecar so a subsequent load can't pick up stale data.
607pub fn invalidate_cache(main_file: &Path) {
608    let cache_file = cache_path(main_file);
609    let _ = fs::remove_file(&cache_file);
610
611    let legacy = legacy_cache_path(main_file);
612    if legacy != cache_file {
613        let _ = fs::remove_file(&legacy);
614    }
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620    use crate::dedup::reintern_directives;
621    use rust_decimal_macros::dec;
622    use rustledger_core::{Amount, Posting, Transaction};
623    use rustledger_parser::Span;
624
625    #[test]
626    fn test_cache_header_roundtrip() {
627        let header = CacheHeader {
628            magic: *CACHE_MAGIC,
629            version: CACHE_VERSION,
630            hash: [42u8; 32],
631            data_len: 12345,
632        };
633
634        let bytes = header.to_bytes();
635        let parsed = CacheHeader::from_bytes(&bytes).unwrap();
636
637        assert_eq!(parsed.magic, header.magic);
638        assert_eq!(parsed.version, header.version);
639        assert_eq!(parsed.hash, header.hash);
640        assert_eq!(parsed.data_len, header.data_len);
641    }
642
643    #[test]
644    fn test_compute_hash_deterministic() {
645        let files: Vec<&Path> = vec![];
646        let hash1 = compute_hash(&files);
647        let hash2 = compute_hash(&files);
648        assert_eq!(hash1, hash2);
649    }
650
651    #[test]
652    fn test_serialize_deserialize_roundtrip() {
653        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
654
655        let txn = Transaction::new(date, "Test transaction")
656            .with_payee("Test Payee")
657            .with_synthesized_posting(Posting::new(
658                "Expenses:Test",
659                Amount::new(dec!(100.00), "USD"),
660            ))
661            .with_synthesized_posting(Posting::auto("Assets:Checking"));
662
663        let directives = vec![Spanned::new(Directive::Transaction(txn), Span::new(0, 100))];
664
665        // Serialize
666        let serialized = serialize_directives(&directives).expect("serialization failed");
667
668        // Deserialize
669        let deserialized = deserialize_directives(&serialized).expect("deserialization failed");
670
671        // Verify roundtrip
672        assert_eq!(directives.len(), deserialized.len());
673        let orig_txn = directives[0].value.as_transaction().unwrap();
674        let deser_txn = deserialized[0].value.as_transaction().unwrap();
675
676        assert_eq!(orig_txn.date, deser_txn.date);
677        assert_eq!(orig_txn.payee, deser_txn.payee);
678        assert_eq!(orig_txn.narration, deser_txn.narration);
679        assert_eq!(orig_txn.postings.len(), deser_txn.postings.len());
680
681        // Check first posting
682        assert_eq!(orig_txn.postings[0].account, deser_txn.postings[0].account);
683        assert_eq!(orig_txn.postings[0].units, deser_txn.postings[0].units);
684    }
685
686    #[test]
687    #[ignore = "manual benchmark - run with: cargo test -p rustledger-loader --release -- --ignored --nocapture"]
688    fn bench_cache_performance() {
689        // Generate test directives
690        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
691        let mut directives = Vec::with_capacity(10000);
692
693        for i in 0..10000 {
694            let txn = Transaction::new(date, format!("Transaction {i}"))
695                .with_payee("Store")
696                .with_synthesized_posting(Posting::new(
697                    "Expenses:Food",
698                    Amount::new(dec!(25.00), "USD"),
699                ))
700                .with_synthesized_posting(Posting::auto("Assets:Checking"));
701
702            directives.push(Spanned::new(Directive::Transaction(txn), Span::new(0, 100)));
703        }
704
705        println!("\n=== Cache Benchmark (10,000 directives) ===");
706
707        // Benchmark serialization
708        let start = std::time::Instant::now();
709        let serialized = serialize_directives(&directives).unwrap();
710        let serialize_time = start.elapsed();
711        println!(
712            "Serialize: {:?} ({:.2} MB)",
713            serialize_time,
714            serialized.len() as f64 / 1_000_000.0
715        );
716
717        // Benchmark deserialization
718        let start = std::time::Instant::now();
719        let deserialized = deserialize_directives(&serialized).unwrap();
720        let deserialize_time = start.elapsed();
721        println!("Deserialize: {deserialize_time:?}");
722
723        assert_eq!(directives.len(), deserialized.len());
724
725        println!(
726            "\nSpeedup potential: If parsing takes 100ms, cache load would be {:.1}x faster",
727            100.0 / deserialize_time.as_millis() as f64
728        );
729    }
730
731    // Note: end-to-end coverage of `cache_path()` (including the
732    // `BEANCOUNT_LOAD_CACHE_FILENAME` env var) lives in
733    // `tests/cache_env_var_test.rs`, which can mutate process env without
734    // tripping the crate's `forbid(unsafe_code)`. The tests below cover the
735    // pure pattern-resolution logic and the legacy-path helper.
736
737    /// Fail fast if a developer has set the cache env vars locally — the
738    /// roundtrip tests in this module call `save_cache_entry`/`invalidate_cache`
739    /// which read process env, and a custom pattern would silently redirect
740    /// writes elsewhere (or fail in surprising ways). CI runs with a clean env.
741    fn assert_clean_cache_env() {
742        for var in [CACHE_FILENAME_ENV, DISABLE_CACHE_ENV] {
743            assert!(
744                std::env::var_os(var).is_none(),
745                "unset {var} before running this test"
746            );
747        }
748    }
749
750    #[test]
751    fn test_resolve_cache_pattern_relative_with_substitution() {
752        let source = Path::new("/home/user/finances/main.beancount");
753        let resolved = resolve_cache_pattern(source, ".cache/{filename}.bin");
754        assert_eq!(
755            resolved,
756            Path::new("/home/user/finances/.cache/main.beancount.bin")
757        );
758    }
759
760    #[test]
761    fn test_resolve_cache_pattern_absolute() {
762        let source = Path::new("/home/user/main.beancount");
763        let resolved = resolve_cache_pattern(source, "/var/cache/rledger/{filename}.cache");
764        assert_eq!(
765            resolved,
766            Path::new("/var/cache/rledger/main.beancount.cache")
767        );
768    }
769
770    #[test]
771    fn test_resolve_cache_pattern_no_substitution() {
772        // Pattern without {filename} is used verbatim.
773        let source = Path::new("/home/user/main.beancount");
774        let resolved = resolve_cache_pattern(source, "fixed.cache");
775        assert_eq!(resolved, Path::new("/home/user/fixed.cache"));
776    }
777
778    #[test]
779    fn test_legacy_cache_path() {
780        let source = Path::new("/tmp/ledger.beancount");
781        assert_eq!(
782            legacy_cache_path(source),
783            Path::new("/tmp/ledger.beancount.cache")
784        );
785    }
786
787    #[test]
788    fn test_save_load_cache_entry_roundtrip() {
789        use std::io::Write;
790
791        assert_clean_cache_env();
792
793        // Create a temp directory
794        let temp_dir = std::env::temp_dir().join("rustledger_cache_test");
795        let _ = fs::create_dir_all(&temp_dir);
796
797        // Create a temp beancount file
798        let beancount_file = temp_dir.join("test.beancount");
799        let mut f = fs::File::create(&beancount_file).unwrap();
800        writeln!(f, "2024-01-01 open Assets:Test").unwrap();
801        drop(f);
802
803        // Create a cache entry
804        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
805        let txn =
806            Transaction::new(date, "Test").with_synthesized_posting(Posting::auto("Assets:Test"));
807        let directives = vec![Spanned::new(Directive::Transaction(txn), Span::new(0, 50))];
808
809        let entry = CacheEntry {
810            directives,
811            options: CachedOptions::from(&Options::new()),
812            plugins: vec![CachedPlugin {
813                name: "test_plugin".to_string(),
814                config: Some("config".to_string()),
815                force_python: false,
816            }],
817            files: vec![beancount_file.to_string_lossy().to_string()],
818        };
819
820        // Save cache
821        save_cache_entry(&beancount_file, &entry).expect("save failed");
822
823        // Load cache
824        let loaded = load_cache_entry(&beancount_file).expect("load failed");
825
826        // Verify
827        assert_eq!(loaded.directives.len(), entry.directives.len());
828        assert_eq!(loaded.plugins.len(), 1);
829        assert_eq!(loaded.plugins[0].name, "test_plugin");
830        assert_eq!(loaded.plugins[0].config, Some("config".to_string()));
831        assert_eq!(loaded.files.len(), 1);
832
833        // Cleanup
834        let _ = fs::remove_file(&beancount_file);
835        let _ = fs::remove_file(cache_path(&beancount_file));
836        let _ = fs::remove_dir(&temp_dir);
837    }
838
839    #[test]
840    fn test_invalidate_cache() {
841        use std::io::Write;
842
843        assert_clean_cache_env();
844
845        let temp_dir = std::env::temp_dir().join("rustledger_invalidate_test");
846        let _ = fs::create_dir_all(&temp_dir);
847
848        let beancount_file = temp_dir.join("test.beancount");
849        let mut f = fs::File::create(&beancount_file).unwrap();
850        writeln!(f, "2024-01-01 open Assets:Test").unwrap();
851        drop(f);
852
853        // Create and save a cache
854        let entry = CacheEntry {
855            directives: vec![],
856            options: CachedOptions::from(&Options::new()),
857            plugins: vec![],
858            files: vec![beancount_file.to_string_lossy().to_string()],
859        };
860        save_cache_entry(&beancount_file, &entry).unwrap();
861
862        // Verify cache exists
863        assert!(cache_path(&beancount_file).exists());
864
865        // Invalidate
866        invalidate_cache(&beancount_file);
867
868        // Verify cache is gone
869        assert!(!cache_path(&beancount_file).exists());
870
871        // Cleanup
872        let _ = fs::remove_file(&beancount_file);
873        let _ = fs::remove_dir(&temp_dir);
874    }
875
876    #[test]
877    fn test_invalidate_cache_removes_legacy_sidecar() {
878        // invalidate_cache should remove both the new dotfile cache and any
879        // pre-#939 visible cache file alongside the source.
880        assert_clean_cache_env();
881
882        let temp_dir = std::env::temp_dir().join("rustledger_invalidate_legacy_test");
883        let _ = fs::create_dir_all(&temp_dir);
884
885        let beancount_file = temp_dir.join("legacy.beancount");
886        // Synthesize a leftover legacy cache file (no need to be valid — we're
887        // only testing that invalidate removes it).
888        let legacy = legacy_cache_path(&beancount_file);
889        fs::write(&legacy, b"stale").unwrap();
890        assert!(legacy.exists());
891
892        invalidate_cache(&beancount_file);
893        assert!(
894            !legacy.exists(),
895            "invalidate_cache should remove the legacy sidecar file"
896        );
897
898        let _ = fs::remove_dir(&temp_dir);
899    }
900
901    #[test]
902    fn test_load_cache_missing_file() {
903        let missing = Path::new("/nonexistent/path/to/file.beancount");
904        assert!(load_cache_entry(missing).is_none());
905    }
906
907    #[test]
908    fn test_load_cache_invalid_magic() {
909        use std::io::Write;
910
911        assert_clean_cache_env();
912
913        let temp_dir = std::env::temp_dir().join("rustledger_magic_test");
914        let _ = fs::create_dir_all(&temp_dir);
915
916        let beancount_file = temp_dir.join("test.beancount");
917        // Write a malformed cache file at the path load_cache_entry will look up.
918        let cache_file = cache_path(&beancount_file);
919        let mut f = fs::File::create(&cache_file).unwrap();
920        // Write invalid magic
921        f.write_all(b"INVALID\0").unwrap();
922        f.write_all(&[0u8; CacheHeader::SIZE - 8]).unwrap();
923        drop(f);
924
925        assert!(load_cache_entry(&beancount_file).is_none());
926
927        // Cleanup
928        let _ = fs::remove_file(&cache_file);
929        let _ = fs::remove_dir(&temp_dir);
930    }
931
932    /// Bumping `CACHE_VERSION` must short-circuit at the header so we
933    /// never feed an older payload to rkyv with the newer schema. Writes
934    /// a header with the correct magic but `version = CACHE_VERSION - 1`
935    /// (e.g., v4 from before #1151's `Vec<Spanned<Posting>>` shape
936    /// change) and asserts the loader refuses it.
937    #[test]
938    fn test_load_cache_rejects_older_version() {
939        use std::io::Write;
940
941        assert_clean_cache_env();
942
943        let temp_dir = std::env::temp_dir().join("rustledger_old_version_test");
944        let _ = fs::create_dir_all(&temp_dir);
945
946        let beancount_file = temp_dir.join("test.beancount");
947        let cache_file = cache_path(&beancount_file);
948        let mut f = fs::File::create(&cache_file).unwrap();
949
950        // Valid magic + previous CACHE_VERSION. The version check at
951        // `load_cache_header` should refuse before any payload is
952        // touched, no matter what the tail bytes look like.
953        let stale_version: u32 = CACHE_VERSION.checked_sub(1).expect("CACHE_VERSION >= 1");
954        f.write_all(CACHE_MAGIC).unwrap();
955        f.write_all(&stale_version.to_le_bytes()).unwrap();
956        f.write_all(&[0u8; CacheHeader::SIZE - 8 - 4]).unwrap();
957        drop(f);
958
959        assert!(
960            load_cache_entry(&beancount_file).is_none(),
961            "loader must reject cache files with an older CACHE_VERSION"
962        );
963
964        let _ = fs::remove_file(&cache_file);
965        let _ = fs::remove_dir(&temp_dir);
966    }
967
968    /// Frozen byte fixtures for the v8 cache layout of
969    /// [`rustledger_core::CostNumber`].
970    ///
971    /// The intra-build distinctness test in `rustledger-core::cost`
972    /// (`cost_number_archived_bytes_snapshot`) only catches drift
973    /// where variants collide with each other. It would NOT catch a
974    /// uniform encoding shift (e.g. a future rkyv minor bump that
975    /// changes how `Archived<Decimal>` packs, or an accidental
976    /// attribute change). When that happens every variant moves
977    /// together so distinctness still holds, but user caches on disk
978    /// silently fail to deserialize as garbage in the new layout.
979    ///
980    /// Capturing the exact bytes here pins the on-disk contract:
981    /// any drift trips this test, forcing the developer to either
982    /// (a) revert the encoding change, or (b) bump
983    /// [`CACHE_VERSION`] so old cache files are short-circuited at
984    /// the header check. The companion `cache_version_matches_v8`
985    /// assertion below fires if a developer regenerates the fixtures
986    /// without bumping the version constant in the same commit.
987    ///
988    /// **If this test fails** and you intend the new encoding to be
989    /// the contract going forward: regenerate the fixtures by
990    /// printing `rkyv::to_bytes(&cn)` for each variant, bump
991    /// `CACHE_VERSION` to `9`, and update both the fixtures and the
992    /// `cache_version_matches_v8` constant below in the same commit.
993    ///
994    /// Gated to little-endian targets — `rkyv::to_bytes` uses native
995    /// endianness, so the hardcoded bytes are valid for `x86_64` /
996    /// `aarch64` but would spuriously fail on big-endian platforms
997    /// (`s390x`, `ppc64be`). `CACHE_VERSION`'s purpose is same-machine
998    /// read guarding, so non-portable bytes aren't a real defect,
999    /// just a test-portability footnote.
1000    #[cfg(target_endian = "little")]
1001    #[test]
1002    fn cost_number_archived_bytes_match_v8_fixtures() {
1003        use rust_decimal_macros::dec;
1004        use rustledger_core::{BookedCost, CostNumber};
1005
1006        // Tripwire: regenerating the byte fixtures below without
1007        // bumping CACHE_VERSION leaves users with rotten caches. The
1008        // assertion fires when CACHE_VERSION advances past 8, forcing
1009        // the developer to also update the fixtures (or remove this
1010        // tripwire if v9's contract is identical to v8 for CostNumber
1011        // — which is unusual but possible).
1012        // v9 (#1340) bumped CACHE_VERSION only to add a `set_options`
1013        // field to `CachedOptions` — the `CostNumber` archived layout
1014        // these fixtures pin is unchanged, so the byte arrays below are
1015        // still valid and only FIXTURE_VERSION moves.
1016        const FIXTURE_VERSION: u32 = 9;
1017        assert_eq!(
1018            CACHE_VERSION, FIXTURE_VERSION,
1019            "CACHE_VERSION advanced past the fixture version; regenerate \
1020             the byte fixtures in this test and update FIXTURE_VERSION, \
1021             or remove the tripwire if v{CACHE_VERSION}'s CostNumber \
1022             encoding is byte-identical to the fixtures.",
1023        );
1024
1025        let cases: &[(&str, CostNumber, &[u8])] = &[
1026            (
1027                "PerUnit { value: 150 }",
1028                CostNumber::PerUnit { value: dec!(150) },
1029                &[
1030                    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,
1031                    0, 0, 0, 0, 0, 0, 0,
1032                ],
1033            ),
1034            (
1035                "Total { value: 1500 }",
1036                CostNumber::Total { value: dec!(1500) },
1037                &[
1038                    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,
1039                    0, 0, 0, 0, 0, 0, 0,
1040                ],
1041            ),
1042            (
1043                "PerUnitFromTotal { per_unit: 150, total: 300 }",
1044                CostNumber::PerUnitFromTotal(BookedCost::new(dec!(150), dec!(300), dec!(2))),
1045                &[
1046                    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,
1047                    0, 0, 0, 0, 0, 0, 0, 0,
1048                ],
1049            ),
1050        ];
1051        let mut mismatches = Vec::new();
1052        for (name, cn, expected) in cases {
1053            let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(cn).unwrap();
1054            if bytes.as_ref() != *expected {
1055                mismatches.push(format!("  `{name}` → {:?}", bytes.as_ref()));
1056            }
1057        }
1058        assert!(
1059            mismatches.is_empty(),
1060            "rkyv layout drifted from v8 fixtures — bump CACHE_VERSION and \
1061             update the fixtures in this test if intentional. Actual bytes:\n{}",
1062            mismatches.join("\n"),
1063        );
1064    }
1065
1066    #[test]
1067    fn test_reintern_directives_deduplication() {
1068        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1069
1070        // Create multiple transactions with the same account
1071        let mut directives = vec![];
1072        for i in 0..5 {
1073            let txn = Transaction::new(date, format!("Txn {i}"))
1074                .with_synthesized_posting(Posting::new(
1075                    "Expenses:Food",
1076                    Amount::new(dec!(10.00), "USD"),
1077                ))
1078                .with_synthesized_posting(Posting::auto("Assets:Checking"));
1079            directives.push(Spanned::new(Directive::Transaction(txn), Span::new(0, 50)));
1080        }
1081
1082        // Re-intern should deduplicate the repeated account names and currencies
1083        let dedup_count = reintern_directives(&mut directives);
1084
1085        // We should have deduplicated:
1086        // - "Expenses:Food" appears 5 times but only first is new (4 dedup)
1087        // - "USD" appears 5 times but only first is new (4 dedup)
1088        // - "Assets:Checking" appears 5 times but only first is new (4 dedup)
1089        // Total: 12 deduplications
1090        assert_eq!(dedup_count, 12);
1091    }
1092
1093    #[test]
1094    fn test_cached_options_roundtrip() {
1095        let mut opts = Options::new();
1096        opts.title = Some("Test Ledger".to_string());
1097        opts.operating_currency = vec!["USD".to_string(), "EUR".to_string()];
1098        opts.render_commas = true;
1099
1100        let cached = CachedOptions::from(&opts);
1101        let restored: Options = cached.into();
1102
1103        assert_eq!(restored.title, Some("Test Ledger".to_string()));
1104        assert_eq!(restored.operating_currency, vec!["USD", "EUR"]);
1105        assert!(restored.render_commas);
1106    }
1107
1108    /// Regression for #1340: `set_options` must survive the cache
1109    /// round-trip. It gates `resolve_effective_booking_method`, so
1110    /// dropping it makes a cache hit re-book FIFO/LIFO ledgers as
1111    /// STRICT (the file-level `option "booking_method"` is ignored).
1112    #[test]
1113    fn test_cached_options_preserves_set_options_for_booking_method() {
1114        let mut opts = Options::new();
1115        // `set()` is what a parsed `option "booking_method" "FIFO"`
1116        // calls — it records both the value AND the set-membership.
1117        opts.set("booking_method", "FIFO");
1118        assert!(opts.set_options.contains("booking_method"));
1119
1120        let cached = CachedOptions::from(&opts);
1121        let restored: Options = cached.into();
1122
1123        assert_eq!(restored.booking_method, "FIFO");
1124        assert!(
1125            restored.set_options.contains("booking_method"),
1126            "set_options dropped across cache round-trip — booking method \
1127             resolution would fall back to the STRICT default on a cache hit"
1128        );
1129    }
1130
1131    #[test]
1132    fn test_cache_entry_file_paths() {
1133        let entry = CacheEntry {
1134            directives: vec![],
1135            options: CachedOptions::from(&Options::new()),
1136            plugins: vec![],
1137            files: vec![
1138                "/path/to/ledger.beancount".to_string(),
1139                "/path/to/include.beancount".to_string(),
1140            ],
1141        };
1142
1143        let paths = entry.file_paths();
1144        assert_eq!(paths.len(), 2);
1145        assert_eq!(paths[0], PathBuf::from("/path/to/ledger.beancount"));
1146        assert_eq!(paths[1], PathBuf::from("/path/to/include.beancount"));
1147    }
1148
1149    #[test]
1150    fn test_reintern_balance_directive() {
1151        use rustledger_core::Balance;
1152
1153        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1154        let balance = Balance::new(date, "Assets:Checking", Amount::new(dec!(1000.00), "USD"));
1155
1156        let mut directives = vec![
1157            Spanned::new(Directive::Balance(balance.clone()), Span::new(0, 50)),
1158            Spanned::new(Directive::Balance(balance), Span::new(51, 100)),
1159        ];
1160
1161        let dedup_count = reintern_directives(&mut directives);
1162        // Second occurrence of "Assets:Checking" and "USD" should be deduplicated
1163        assert_eq!(dedup_count, 2);
1164    }
1165
1166    #[test]
1167    fn test_reintern_open_close_directives() {
1168        use rustledger_core::{Close, Open};
1169
1170        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1171        let open = Open::new(date, "Assets:Checking");
1172        let close = Close::new(date, "Assets:Checking");
1173
1174        let mut directives = vec![
1175            Spanned::new(Directive::Open(open), Span::new(0, 50)),
1176            Spanned::new(Directive::Close(close), Span::new(51, 100)),
1177        ];
1178
1179        let dedup_count = reintern_directives(&mut directives);
1180        // Second "Assets:Checking" should be deduplicated
1181        assert_eq!(dedup_count, 1);
1182    }
1183}