Skip to main content

fdu_core/
lib.rs

1//! **fdu** — a fast, incremental file roll-up engine.
2//!
3//! fdu answers, for any directory in a tree: how big is it, how many files does it hold,
4//! what changed most recently, and what kinds of files live in it — hierarchically, for
5//! every directory at once, from a single walk.
6//!
7//! # The shape: three artifacts, one contract
8//!
9//! 1. **The index** ([`Index`]) — the in-memory hierarchical structure: entry
10//!    records plus per-directory roll-up state.
11//! 2. **The snapshot** ([`snapshot`]) — that index, serialized.
12//! 3. **The change contract** ([`Observation`] and [`Commit`]) —
13//!    producers submit verified observations; the index commits clocked effective
14//!    changes.
15//!
16//! Everything else is a producer of observations or a consumer of exact commits. The
17//! walker establishes a baseline from upsert observations; the reconciler submits the
18//! conditional diff between indexed state and reality; the watch layer submits verified,
19//! coalesced observations. The index arbitrates them and re-rolls its reducers; a change
20//! feed consumes exact effective changes and state transitions from [`Commit`].
21//!
22//! A deliberate consequence: **watching is not tied to the roll-up logic.** The index
23//! knows `apply(Observation)` and nothing about filesystem events, so a batch scan, a test
24//! feeding synthetic observations, and a live watcher are indistinguishable to it.
25//!
26//! # Freshness is a ladder, not a set of alternatives
27//!
28//! [`open`] is the conservative, blocking entry point: it loads a compatible snapshot,
29//! reconciles the configured filesystem scope, and only then returns. It does not serve
30//! the loaded baseline concurrently. Applications that want that model can own an
31//! [`IndexHandle`], call the applying reconciliation APIs, and inspect [`Freshness`]
32//! while readers continue between short write batches. With the `watch` feature,
33//! `watch::Watcher::apply_next` verifies event hints and closes invalidations through
34//! subtree reconciliation; neither `open` nor the Python binding starts it implicitly.
35//! [`OpenedIndex`] is the additive long-lived owner: its clones share one live identity,
36//! cancellation domain, index, and joined shutdown. A cloned [`Index`] remains a
37//! detached image and never inherits that authority.
38//!
39//! ```no_run
40//! use fdu_core::{CachePolicy, open};
41//! use fdu_core::query::{Basis, Delivery, Scope};
42//! use std::path::Path;
43//!
44//! let basis = Basis { root: Path::new(".").into(), scope: Scope::default(), content: Default::default() };
45//! let (index, report) = open(&basis, &Delivery::new(CachePolicy::Auto, None))?;
46//! let total = index.total();
47//! println!("{} files, {} bytes ({:?})", total.files, total.bytes, report.path_taken);
48//! # Ok::<(), fdu_core::Error>(())
49//! ```
50//!
51//! # Build features
52//!
53//! - `watch` — the OS-native watch layer.
54//!
55//! `fdu-core` has no default build features. The command and Python packages opt into
56//! watch, while embedding consumers can retain the smaller one-shot engine.
57//!
58//! `.gitignore` handling is not a build feature: it has no dependency, so it is always
59//! compiled in, and whether a scan reads control files is decided at runtime by
60//! [`ScanConfig::read_controls`].
61
62pub mod admission;
63pub mod cache;
64pub mod classify;
65pub mod content;
66pub mod control;
67pub mod counters;
68mod emit;
69mod engine_contract;
70mod execution;
71mod index;
72mod opened;
73mod platform_tuning;
74mod progress;
75pub mod query;
76pub mod scan;
77pub mod snapshot;
78mod stored_state;
79#[cfg(test)]
80mod test_support;
81
82/// The crate README's Rust examples, compiled and run as doctests.
83///
84/// crates.io shows the README as this crate's front page, so its examples are the first
85/// code a reader copies. Nothing compiled them, and they kept naming an `AnalysisProfile`
86/// type for a release after the content axis became `AnalysisSet`.
87#[cfg(doctest)]
88#[doc = include_str!("../README.md")]
89pub struct ReadmeDoctests;
90
91// Ungated: rendering is not a command-line concern. It was behind `cli` only because it
92// took its ANSI colour types from clap, so the library could produce a report and not
93// print it -- and a display note added elsewhere on this branch called into here and
94// broke the no-default-features build, which is that gap showing itself.
95pub mod report_format;
96
97#[cfg(feature = "watch")]
98pub mod watch_session;
99
100#[cfg(feature = "watch")]
101pub mod watch;
102
103#[cfg(feature = "watch")]
104pub use crate::watch_session as session;
105
106pub use crate::admission::HiddenPolicy;
107pub use crate::cache::{
108    CacheScope, CacheState, CacheStatus, ClearSummary, ContentInfo, ContentState, ContentStatus,
109    LeftoverKind, SnapshotInfo, StaleReason, cache_status, clear_all_caches, clear_cache,
110    list_caches,
111};
112pub use crate::control::{
113    CONTROL_FILE_NAME, ControlAdmission, ControlCoverage, ControlIdentity, ControlLimits,
114    ControlMatcher, ControlObservation, ControlRefusalReason, ControlTable, DEFAULT_CONTROL_BUDGET,
115    DEFAULT_CONTROL_LINE_LIMIT, RefusedControl, is_control_file,
116};
117pub use crate::engine_contract::{
118    Attrs, ChangeOutcome, ChangePoll, ChangeRequest, Clock, Commit, ContinuationId, CountResult,
119    Coverage, CoverageReason, DEFAULT_COUNT_CAP, DiscoveryProgress, EffectiveChange, EngineVersion,
120    EntryKind, EntryValue, Error, Expectation, Fingerprint, FlatPage, Freshness, Impact,
121    ImpactDomain, IndexState, InvalidateReason, Issue, IssueKind, IssueSummary, Knowledge,
122    LifecyclePhase, LimitedProjection, MAX_CONTINUATION_RECORD_BYTES, MAX_COUNT_CAP,
123    MAX_DIRTY_PATHS, MAX_ISSUE_MESSAGE_BYTES, MAX_ISSUE_PATH_BYTES, MAX_PAGE_ROWS, MAX_PAGE_WORK,
124    MAX_READ_PROJECTIONS, MAX_REPORT_VIEWS, MAX_RETAINED_ISSUES, MIN_JOURNAL_CAPACITY_BYTES,
125    Observation, ObservationOp, Op, PageRequest, PathExpectation, PathState, PortablePath,
126    ProjectionRefusal, ProjectionResult, Provenance, QueryLimit, ReadDiagnostics, ReadProjection,
127    ReadRequest, ReadResponse, RefreshRejection, RefreshResult, RejectedRefreshPath, ReportRequest,
128    Result, RowShape, ScanScope, SemanticIdentity, SessionId, Source, StateTransition, Status,
129    TreePage, Work,
130};
131pub use crate::index::{
132    ApplyOutcome, ApplyStats, ChildSnapshot, DEFAULT_JOURNAL_CAPACITY_BYTES, EntryId, ExtTally,
133    Index, IndexHandle, PartitionRollUp, PartitionRollUpSummary, RollUp, RollUpSummary, Since,
134};
135pub use crate::opened::{
136    DiscoveryBudget, MAX_PRIORITY_PATHS, MAX_REFRESH_PATHS, OpenOptions, OpenedIndex,
137};
138// Ungated with report_format, for the same reason: one-shot planning is an execution
139// strategy, not a front end. A caller wanting one report without retaining an index was
140// previously required to compile the command line to get it (fdu-z7sp).
141pub use crate::execution::{
142    Load, OutcomeClass, PerformanceSummary, Plan, Route, Verify, plan, prepare_report,
143    prepare_report_with_progress, prepare_report_with_scan_diagnostics,
144};
145pub use crate::progress::{Progress, ProgressPhase, ProgressSnapshot};
146pub use crate::scan::{ReconcileReport, ScanConfig, ScanOrder, ScanReport};
147pub use crate::stored_state::{
148    AnalyzerProvenance, ContentAdmission, ContentTierIdentity, ControlTierIdentity, EntryScope,
149    EntryTierIdentity, Serves, SnapshotIdentity, serves_snapshot,
150};
151#[cfg(feature = "watch")]
152pub use crate::watch_session::{Batch, Change, ChangeKind, SaveOutcome, Session};
153
154use crate::execution::{Admission, RunFacts, SaveTargets, StoreHeader};
155use std::ffi::OsString;
156use std::path::{Path, PathBuf};
157
158/// How to open a tree.
159#[cfg(test)]
160#[derive(Clone, Debug, Default)]
161pub(crate) struct OpenFixture {
162    /// Walk settings.
163    pub scan: ScanConfig,
164    /// Where the snapshot for this root lives.
165    ///
166    /// `None` disables the cache regardless of policy, which is what a caller with no
167    /// writable cache directory gets.
168    pub cache_path: Option<PathBuf>,
169    /// How the snapshot may be used.
170    pub policy: CachePolicy,
171    /// Optional streaming content analysis. Disabled preserves metadata-only behavior.
172    pub analysis: content::AnalysisRequest,
173}
174
175#[cfg(test)]
176impl OpenFixture {
177    /// Compose model inputs for a unit-test fixture.
178    pub fn split(&self, root: impl Into<PathBuf>) -> (query::Basis, query::Delivery) {
179        (
180            query::Basis {
181                root: root.into(),
182                scope: self.scan.clone().into(),
183                content: self.analysis.profile,
184            },
185            query::Delivery {
186                cache: self.policy,
187                cache_path: self.cache_path.clone(),
188                accept_partial: false,
189                watch: None,
190                workers: query::Workers {
191                    scan: self.scan.threads,
192                    analysis: self.analysis.workers,
193                },
194                batch_size: self.scan.batch_size,
195                order: self.scan.order,
196            },
197        )
198    }
199}
200
201#[cfg(test)]
202pub(crate) fn open_fixture(root: &Path, config: &OpenFixture) -> Result<(Index, OpenReport)> {
203    let (basis, delivery) = config.split(root);
204    open(&basis, &delivery)
205}
206#[cfg(test)]
207pub(crate) fn open_fixture_with_pending_save(
208    root: &Path,
209    config: &OpenFixture,
210) -> Result<(std::sync::Arc<Index>, OpenReport, PendingSave)> {
211    let (basis, delivery) = config.split(root);
212    open_with_pending_save(&basis, &delivery)
213}
214
215/// How an [`open`] may use the snapshot cache.
216///
217/// One explicit axis rather than a pair of booleans, because "did this answer touch the
218/// filesystem" and "did it leave a trace" are the two questions a caller actually has,
219/// and a boolean pair can express combinations that have no meaning.
220#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
221pub enum CachePolicy {
222    /// Read the snapshot, revalidate it, and write it back when the scan is complete.
223    ///
224    /// A root has one cache path, and its snapshot carries the scan scope that wrote it.
225    /// A read under another scope normally treats that snapshot as absent and scans cold.
226    /// The lawful exception is a controls-off request with the same entry identity: it
227    /// projects a controls-on snapshot into the requested blind scope and never replaces
228    /// the stronger image with that projection.
229    ///
230    /// Every default request observes `.gitignore` control state -- the one-shot
231    /// `fdu <dir>` and [`prepare_report`], `fdu --watch <dir>`, and a default [`open`] --
232    /// so they share one scope: an [`open`] or a watch starts warm from a one-shot report's
233    /// snapshot, and a report that reads the snapshot, as content analysis does, starts
234    /// warm from theirs. A request that turns [`ScanConfig::read_controls`] off is a second
235    /// scope, but every route can start from a default snapshot by discarding its control
236    /// tier while loading. A summary-only report that turned observation off saves nothing
237    /// and replaces nothing.
238    #[default]
239    Auto,
240    /// Ignore any snapshot, scan cold, and rewrite it. The benchmark control.
241    Refresh,
242    /// Read and revalidate, but never write. A warm answer that leaves no trace.
243    ReadOnly,
244    /// Answer from the snapshot alone, without touching the tree.
245    ///
246    /// Fails when no usable snapshot exists: there is no data to answer with, and
247    /// silently falling back to a scan would make the fast path unpredictable.
248    Only,
249    /// Ignore the snapshot entirely and leave nothing behind.
250    Off,
251}
252
253impl CachePolicy {
254    /// Whether this policy may read an existing snapshot.
255    fn reads(self) -> bool {
256        matches!(self, Self::Auto | Self::ReadOnly | Self::Only)
257    }
258
259    /// Whether this policy may write a snapshot back.
260    ///
261    /// Public because a caller deciding whether to prepare a cache directory, or to warn
262    /// that a run will leave nothing behind, is asking about the policy it was handed --
263    /// and the alternative is matching on the variants, which is the same knowledge
264    /// copied into every caller.
265    pub fn writes(self) -> bool {
266        matches!(self, Self::Auto | Self::Refresh)
267    }
268}
269
270/// Which tier of the freshness ladder an [`open`] actually used.
271#[derive(Clone, Copy, PartialEq, Eq, Debug)]
272pub enum OpenPath {
273    /// No usable snapshot: the tree was walked from scratch.
274    ColdScan,
275    /// A snapshot was loaded and reconciled against the filesystem.
276    WarmRevalidate,
277    /// A snapshot answered on its own; the filesystem was never consulted.
278    ///
279    /// The only tier that can be stale, and it says so rather than implying currency.
280    CacheOnly,
281}
282
283/// A snapshot write running alongside rendering.
284///
285/// The index is read-only by the time this starts, so the writer and the renderer are
286/// two readers of the same data. The handle exists so the process can join before it
287/// exits: an abandoned write would leave a half-written snapshot for the next run to
288/// reject, turning a warm start into a cold one for no reason.
289#[derive(Debug)]
290#[must_use = "join the save before exiting or the snapshot may be abandoned"]
291pub struct PendingSave {
292    workers: Vec<(&'static str, std::thread::JoinHandle<Result<()>>)>,
293}
294
295impl PendingSave {
296    /// Nothing to wait for.
297    pub(crate) fn none() -> Self {
298        Self { workers: Vec::new() }
299    }
300
301    /// Whether the metadata snapshot is among the writes being waited for.
302    pub(crate) fn writes_metadata(&self) -> bool {
303        self.workers.iter().any(|(name, _)| *name == "metadata")
304    }
305
306    /// Wait for the write to finish, returning its result.
307    ///
308    /// A failed save is the caller's to report, not to die on: the answer already
309    /// rendered is still correct, and only the next run's warmth is lost.
310    pub fn join(mut self) -> Result<()> {
311        let mut first_error = None;
312        for (name, worker) in self.workers.drain(..) {
313            let outcome = worker
314                .join()
315                .unwrap_or_else(|_| Err(Error::Snapshot(format!("{name} cache writer panicked"))));
316            if first_error.is_none() {
317                first_error = outcome.err();
318            }
319        }
320        first_error.map_or(Ok(()), Err)
321    }
322}
323
324impl Drop for PendingSave {
325    fn drop(&mut self) {
326        // A dropped handle still waits: losing the write silently would be worse than
327        // the brief delay, and this only happens on a path that forgot to join.
328        for (_, worker) in self.workers.drain(..) {
329            let _ = worker.join();
330        }
331    }
332}
333
334/// What [`open`] did.
335#[derive(Debug)]
336pub struct OpenReport {
337    /// Cache tier used to produce the returned index.
338    pub path_taken: OpenPath,
339    /// Filesystem walk results, including any partial errors.
340    pub scan: ScanReport,
341    /// Content-analysis work performed after metadata reconciliation.
342    pub analysis: Option<content::AnalysisReport>,
343    /// Reusable records restored from the independently versioned content sidecar.
344    pub content_cache: content::ContentCacheLoad,
345    /// Whether the returned index was projected from a stronger controls-on snapshot.
346    pub projected: bool,
347}
348
349impl OpenReport {
350    /// Whether every path in the requested scan scope was read successfully.
351    pub fn is_complete(&self) -> bool {
352        self.scan.is_complete()
353            && self.analysis.as_ref().is_none_or(content::AnalysisReport::is_complete)
354    }
355
356    /// Per-path errors that make this result partial.
357    pub fn errors(&self) -> &[Error] {
358        &self.scan.errors
359    }
360
361    /// Human-readable diagnostics for every operational condition that makes this result partial.
362    pub fn error_messages(&self) -> Vec<String> {
363        let mut errors = self.scan.errors.iter().map(ToString::to_string).collect::<Vec<_>>();
364        if let Some(message) =
365            self.analysis.as_ref().and_then(content::AnalysisReport::failure_message)
366        {
367            errors.push(message);
368        }
369        errors
370    }
371}
372
373/// Open a tree, using the snapshot cache when one is usable.
374///
375/// On the warm path the snapshot is loaded and then reconciled against the filesystem
376/// before being returned. Errors are represented as partial freshness and the previous
377/// complete snapshot is left untouched; callers must inspect [`OpenReport::is_complete`]
378/// or [`Index::freshness`] before treating totals as complete.
379///
380/// The index observes control state as [`ScanConfig::read_controls`] says, and the
381/// default is on: the index exposes [`Index::controls`] and [`Index::is_ignored`], and a
382/// watch over it maintains them. A one-shot report from [`prepare_report`] observes it on
383/// the same terms, so a default `open` and a default report share one snapshot scope and
384/// each starts warm from the other's snapshot. A caller wanting a single answer should use
385/// [`prepare_report`].
386///
387/// A caller that reads no ignore classification may turn the field off. Its `open` reads
388/// no `.gitignore`, and its index answers [`Index::is_ignored`] and [`Index::controls`]
389/// with [`Error::ControlStateNotObserved`] rather than calling every entry unignored. Its
390/// snapshot is of another scope. When its entry identity matches a default snapshot, the
391/// loader discards that snapshot's control tier and constructs the returned index directly
392/// in the requested controls-off scope. The projected index never replaces the stronger
393/// snapshot, including during a watch session.
394pub fn open(basis: &query::Basis, delivery: &query::Delivery) -> Result<(Index, OpenReport)> {
395    let (index, report, pending) = open_with_pending_save(basis, delivery)?;
396    // Joining first is what makes the unwrap infallible: the writer held the only other
397    // reference, and this is the blocking entry point, so by here it has finished and
398    // dropped it. `try_unwrap` rather than a clone keeps the owned-`Index` signature
399    // honest — a fallback clone here would quietly reintroduce the copy the shared
400    // writer exists to avoid.
401    let wrote_metadata = pending.writes_metadata();
402    pending.join()?;
403    let mut index = std::sync::Arc::into_inner(index)
404        .expect("the joined writer released the only other reference");
405    // Only here, after the join succeeded: a write that failed leaves the debt on the
406    // index, and a caller of [`open_with_pending_save`] who keeps the index through a
407    // failed write keeps the debt with it, so its next writing pass writes.
408    if wrote_metadata {
409        index.set_persistence_owed(false);
410    }
411    Ok((index, report))
412}
413
414/// Open a tree, returning the snapshot write for the caller to join.
415///
416/// The blocking [`open`] is the right default; a caller that renders its own output can
417/// use this to overlap the write with rendering and join before exiting.
418///
419/// This path always loads a usable snapshot, because its callers — live sessions and
420/// library consumers holding the index — amortise the load across everything they do
421/// with it. A one-shot report cannot; the internal report planner decides per request
422/// whether the read pays and routes through the gated variant below.
423pub fn open_with_pending_save(
424    basis: &query::Basis,
425    delivery: &query::Delivery,
426) -> Result<(std::sync::Arc<Index>, OpenReport, PendingSave)> {
427    let request =
428        query::Request::new(basis.clone(), query::Query::default(), std::time::SystemTime::now());
429    let plan = plan(&request, delivery, Route::Retained).map_err(Error::InvalidRequest)?;
430    execute(&plan, &request.basis, false, None)
431        .map(|(index, report, pending, _diagnostics)| (index, report, pending))
432}
433
434/// Reverify a retained index, refresh requested content, and persist according to delivery.
435///
436/// A partial pass retains its verified facts and may save only the content tier beside
437/// a compatible complete snapshot. The returned report describes metadata changes.
438///
439/// The metadata write is owed by the index, not by the pass: a pass that mutated the
440/// entry tier without writing it -- because it was partial, or its policy does not write
441/// -- leaves the index holding facts the snapshot lacks, and the next complete pass under
442/// a writing policy writes them even when it changed nothing itself. Only a completed
443/// write clears that debt, so a failed one is retried by the next pass as well.
444pub fn refresh(
445    index: &mut Index,
446    basis: &query::Basis,
447    delivery: &query::Delivery,
448) -> Result<ReconcileReport> {
449    let request =
450        query::Request::new(basis.clone(), query::Query::default(), std::time::SystemTime::now());
451    let plan = plan(&request, delivery, Route::Refresh).map_err(Error::InvalidRequest)?;
452    validate_basis_root(index.root_path(), basis)?;
453    request.validate_read(&query::Basis::held_by(index)).map_err(Error::InvalidRequest)?;
454    let scan = basis.scope.scan_config(delivery);
455    scan.validate_for_scope(index.scope())?;
456    // `plan` refuses the one policy that verifies nothing on this route, so there is no
457    // second refusal here to keep in step with it.
458    debug_assert_eq!(plan.verify(), Verify::Filesystem, "a refresh plan verifies the tree");
459    let report = scan::reconcile(index, &scan, &mut |_| {})?;
460    let cached = load_content(index, basis, delivery)?;
461    let analysis = basis.content.is_enabled().then(|| {
462        content::analyze_index(
463            index,
464            content::AnalysisRequest { profile: basis.content, workers: delivery.workers.analysis },
465        )
466    });
467    if report.apply.mutated() {
468        index.set_persistence_owed(true);
469    }
470    let written = persist_index_changes(
471        index,
472        &plan,
473        index.persistence_owed(),
474        !cached.usable
475            || cached.stale > 0
476            || analysis.as_ref().is_some_and(|report| report.applied > 0),
477    )?;
478    if written {
479        index.set_persistence_owed(false);
480    }
481    Ok(report)
482}
483
484pub(crate) fn validate_basis_root(held: &Path, basis: &query::Basis) -> Result<()> {
485    let requested = basis.root.canonicalize().map_err(|error| Error::io(&basis.root, error))?;
486    let held = held.canonicalize().map_err(|error| Error::io(held, error))?;
487    if requested != held {
488        return Err(Error::InvalidRequest(query::RequestError::RootMismatch { held, requested }));
489    }
490    Ok(())
491}
492
493/// Why a policy that cannot scan has no snapshot to answer from, and what recovers.
494///
495/// [`CachePolicy::Only`] is the one policy that cannot fall back to a scan, so its failure
496/// is the only place a caller learns that the snapshot is missing or of another scope.
497/// Type rules determine each file's category, so attaching another registry would make a
498/// cache-only answer false. Control state is another common difference: a snapshot
499/// without it was written by a request that turned observation off, or by a
500/// release from before observation was the default, and a default cache-only request after
501/// it would otherwise fail with no hint that a snapshot exists at all. Control limits: both
502/// scopes observe, and their identities are hashes, so "a different scan scope" would
503/// describe a request whose only difference is a limit the caller chose and can choose
504/// again.
505fn unusable_snapshot_message(refused: Option<SnapshotIdentity>, wanted: &ScanConfig) -> String {
506    // Not "run once under `auto` to write one": a compact summary scans without retaining
507    // an index and writes nothing, so that remedy would fail again for exactly that query.
508    const PREFIX: &str = "no usable snapshot for this root and scan scope";
509    const NEVER_SCANS: &str = "the `only` cache policy never scans";
510    let wanted_scope = wanted.scope();
511    let differs_only_in_ignore_rules = |stored: ScanScope| {
512        ScanScope { ignore_rules_fingerprint: wanted_scope.ignore_rules_fingerprint, ..stored }
513            == wanted_scope
514    };
515    let Some(refused) = refused else {
516        return format!("{PREFIX}; {NEVER_SCANS}, so use `auto`, which scans when none serves");
517    };
518    let stored = refused.scan_scope();
519    if stored.type_rules_fingerprint != wanted_scope.type_rules_fingerprint {
520        return format!(
521            "{PREFIX}: the cached snapshot was taken under different file type rules; \
522             {NEVER_SCANS}, so use the snapshot's type registry or use `auto`, which scans under this request's rules"
523        );
524    }
525    if differs_only_in_ignore_rules(stored) {
526        // Named without a knob, because the command line and the library spell the switch
527        // differently and this message is the engine's.
528        if stored.ignore_rules_fingerprint == 0 {
529            return format!(
530                "{PREFIX}: the cached snapshot has no .gitignore state, because the request \
531                 that wrote it did not observe it, and this request does; {NEVER_SCANS}, so \
532                 use `auto`, or turn .gitignore observation off as that request did"
533            );
534        }
535        if wanted_scope.observes_controls() {
536            let stored_limits = match refused.controls {
537                ControlTierIdentity::Observed { limits } => limits,
538                ControlTierIdentity::NotObserved => crate::control::ControlLimits::default(),
539            };
540            let changed = changed_control_limits(stored_limits, wanted.control_limits);
541            if !changed.is_empty() {
542                return format!(
543                    "{PREFIX}: the cached snapshot was taken under other .gitignore limits \
544                     ({changed}); {NEVER_SCANS}, so repeat the request with the snapshot's \
545                     limits, or use `auto`, which scans when none serves"
546                );
547            }
548        }
549    }
550    format!(
551        "{PREFIX}: the cached snapshot has a different scan scope; {NEVER_SCANS}, so use \
552         `auto`, which scans when none serves"
553    )
554}
555
556/// Each control limit that differs between a snapshot and a request, both values named.
557fn changed_control_limits(
558    stored: crate::control::ControlLimits,
559    wanted: crate::control::ControlLimits,
560) -> String {
561    let display = crate::control::limit_display;
562    let changed: Vec<String> = [
563        ("budget", stored.budget, wanted.budget),
564        ("line limit", stored.line_limit, wanted.line_limit),
565    ]
566    .into_iter()
567    .filter(|(_, stored, wanted)| stored != wanted)
568    .map(|(name, stored, wanted)| {
569        format!("{name} {}, where this request asks for {}", display(stored), display(wanted))
570    })
571    .collect();
572    changed.join(", and ")
573}
574
575/// [`open_with_pending_save`] with the snapshot read under the caller's control.
576///
577/// `read_snapshot: false` skips loading an existing snapshot and takes the cold-scan
578/// path: for a one-shot metadata query, revalidation stats every entry regardless, so
579/// the load and the reconciliation against it are additive cost with nothing to
580/// amortise them — measured on macOS/APFS over 494,031 entries, warm revalidation cost
581/// 4.8 s against 3.6 s for the cold path, whose write-behind the read could at best
582/// have saved ~50 ms of. Persistence is unaffected: the cold path still writes per
583/// [`Plan::writes`], so the snapshot stays fresh for [`CachePolicy::Only`]
584/// and for content-analysis reuse.
585///
586/// A policy that cannot scan reads regardless of the flag — for [`CachePolicy::Only`]
587/// the snapshot is the contract, not a cost choice.
588pub(crate) fn execute(
589    plan: &Plan,
590    basis: &query::Basis,
591    collect_scan_diagnostics: bool,
592    progress: Option<&Progress>,
593) -> Result<(std::sync::Arc<Index>, OpenReport, PendingSave, Option<scan::ScanDiagnostics>)> {
594    let scan_config =
595        ScanConfig { progress: progress.cloned(), ..basis.scope.scan_config(plan.delivery()) };
596    let analysis_request = content::AnalysisRequest {
597        profile: basis.content,
598        workers: plan.delivery.workers.analysis,
599    };
600    let delivery = plan.delivery();
601    let root = &basis.root;
602    let root = root.canonicalize().map_err(|e| Error::io(root, e))?;
603    // Before the snapshot, not at the scan that may never happen: a scope this build cannot
604    // honour has no answer at any delivery, and checking it where the scan runs made
605    // `--cache only` report a snapshot miss for a request every other policy refuses --
606    // which failure a run named then depended on how it was delivered (`refusal-order`).
607    scan_config.validate()?;
608    let canonical_basis = query::Basis { root: root.clone(), ..basis.clone() };
609    // What the store holds, for the plan to admit: the index a served snapshot supplied,
610    // and the root and identity the snapshot declares whether or not it served, kept so a
611    // policy that cannot scan says why it has no answer rather than only that it has none.
612    let mut loaded = None;
613    let mut stored: Option<(PathBuf, SnapshotIdentity)> = None;
614    if let (Load::Snapshot, Some(cache_path)) = (plan.load(), &delivery.cache_path) {
615        if let Some(progress) = progress {
616            progress.enter(ProgressPhase::Loading);
617        }
618        match snapshot::load_serving(
619            cache_path,
620            scan_config.types_shared(),
621            scan_config.snapshot_identity(),
622        )? {
623            snapshot::LoadOutcome::Served { index, stored: identity } => {
624                stored = Some((index.root_path().to_path_buf(), identity));
625                loaded = Some(index);
626            }
627            snapshot::LoadOutcome::Refused { identity, root: stored_root } => {
628                stored = Some((stored_root, identity));
629            }
630            snapshot::LoadOutcome::Absent => {}
631        }
632    }
633    let cache_only = plan.verify() == Verify::None;
634    // A policy that cannot scan is admitted on its content tier too, so its sidecar is
635    // loaded before the one admission below. A verifying route loads it after reconciling.
636    // Deliberately no reconciliation there: that tier never touches the tree, and the
637    // index is marked unverified so the answer cannot claim a currency it has not earned
638    // -- a snapshot records the freshness it was written with, which was true then.
639    let content_cache = match (&mut loaded, cache_only) {
640        (Some(index), true) => {
641            index.mark_unverified();
642            Some(load_content(index, basis, delivery)?)
643        }
644        _ => None,
645    };
646    // The one admission of stored state on every route that reads it. A sidecar serves
647    // only its own identity, so restoring one record per visited regular file means it
648    // holds the complete answer to this request: restore already walked that set, so
649    // compare `hits` to files visited, not a second walk and not unique `PathBuf` keys.
650    let admission = {
651        let header = stored.as_ref().map(|(stored_root, identity)| StoreHeader {
652            root: stored_root,
653            snapshot: *identity,
654            content: loaded
655                .as_ref()
656                .and_then(Index::content)
657                .and_then(|content| content.identity()),
658            content_complete: content_cache
659                .as_ref()
660                .is_some_and(|cache| cache.usable && cache.hits == cache.candidates),
661        });
662        plan.admit(header.as_ref(), &canonical_basis)
663    };
664
665    if cache_only {
666        let relation = match admission {
667            Admission::Serve(relation) => relation,
668            Admission::NoLocation => {
669                return Err(Error::Snapshot(
670                    "no cache location is configured; configure a cache location before requesting cache-only access".into(),
671                ));
672            }
673            Admission::WrongRoot => {
674                return Err(Error::Snapshot(
675                    "the configured snapshot belongs to a different root; choose this root's cache location or use auto to replace the snapshot for this root".into(),
676                ));
677            }
678            Admission::Missing => {
679                return Err(Error::Snapshot(unusable_snapshot_message(None, &scan_config)));
680            }
681            Admission::WrongScope => {
682                return Err(Error::Snapshot(unusable_snapshot_message(
683                    stored.map(|(_, identity)| identity),
684                    &scan_config,
685                )));
686            }
687            Admission::IncompleteContent => {
688                return Err(Error::Snapshot(
689                    "no complete usable content sidecar for this root and analysis profile".into(),
690                ));
691            }
692        };
693        let index = loaded.expect("a served admission is of the loaded snapshot");
694        let content_cache =
695            content_cache.expect("the sidecar was loaded beside the served snapshot");
696        return Ok((
697            std::sync::Arc::new(index),
698            OpenReport {
699                path_taken: OpenPath::CacheOnly,
700                scan: ScanReport::default(),
701                analysis: None,
702                content_cache,
703                projected: relation == Serves::ProjectControlsOff,
704            },
705            PendingSave::none(),
706            None,
707        ));
708    }
709
710    if let Admission::Serve(relation) = admission {
711        let mut index = loaded.expect("a served admission is of the loaded snapshot");
712        let projected = relation == Serves::ProjectControlsOff;
713        let reconciled = scan::reconcile(&mut index, &scan_config, &mut |_| {})?;
714        let scan_report = reconciled.scan;
715        index.establish_baseline();
716        let content_cache = load_content(&mut index, basis, delivery)?;
717        let analysis = basis
718            .content
719            .is_enabled()
720            .then(|| content::analyze_index_observed(&mut index, analysis_request, progress));
721        // A reconciliation that mutated nothing leaves an index that serializes to the
722        // bytes already on disk, so rewriting it is pure cost: the clone, the encode,
723        // and the write all produce a file identical to the one just read. Each artifact
724        // is judged separately because content and metadata are invalidated separately.
725        if reconciled.apply.mutated() {
726            index.set_persistence_owed(true);
727        }
728        let facts = run_facts(
729            &index,
730            basis,
731            delivery,
732            index.persistence_owed(),
733            analysis.as_ref().is_some_and(|report| report.applied > 0) || content_cache.stale > 0,
734            projected,
735            || stored_entries(delivery, &root),
736        );
737        // The index is shared read-only from here, so the debt a completed write clears
738        // is cleared by the blocking [`open`] once it has joined the write.
739        let index = std::sync::Arc::new(index);
740        let pending = spawn_save(&index, &plan.delivery, plan.writes(facts), progress);
741        return Ok((
742            index,
743            OpenReport {
744                path_taken: OpenPath::WarmRevalidate,
745                scan: scan_report,
746                analysis,
747                content_cache,
748                projected,
749            },
750            pending,
751            None,
752        ));
753    }
754
755    let (mut index, scan_report, scan_diagnostics) = if collect_scan_diagnostics {
756        let (index, report, diagnostics) =
757            scan::scan_into_index_with_diagnostics(&root, &scan_config)?;
758        (index, report, Some(diagnostics))
759    } else {
760        let (index, report) = scan::scan_into_index(&root, &scan_config)?;
761        (index, report, None)
762    };
763    let content_cache = load_content(&mut index, basis, delivery)?;
764    let analysis = basis
765        .content
766        .is_enabled()
767        .then(|| content::analyze_index_observed(&mut index, analysis_request, progress));
768    let facts =
769        run_facts(&index, basis, delivery, true, true, false, || stored_entries(delivery, &root));
770    let index = std::sync::Arc::new(index);
771    let pending = spawn_save(&index, &plan.delivery, plan.writes(facts), progress);
772    Ok((
773        index,
774        OpenReport {
775            path_taken: OpenPath::ColdScan,
776            scan: scan_report,
777            analysis,
778            content_cache,
779            projected: false,
780        },
781        pending,
782        scan_diagnostics,
783    ))
784}
785
786/// The entry tier the snapshot at the delivery's cache location declares for `root`.
787///
788/// Read for the content tier's pairing rule alone, by a route that attempted no load;
789/// a route that did reads the header it already holds.
790fn stored_entries(delivery: &query::Delivery, root: &Path) -> Option<EntryTierIdentity> {
791    delivery
792        .cache_path
793        .as_ref()
794        .and_then(|path| snapshot::read_header(path).ok().flatten())
795        .filter(|stored| stored.root == root)
796        .map(|stored| stored.identity.entries)
797}
798
799fn run_facts(
800    index: &Index,
801    basis: &query::Basis,
802    delivery: &query::Delivery,
803    entries_changed: bool,
804    content_changed: bool,
805    projected: bool,
806    stored_entries: impl FnOnce() -> Option<EntryTierIdentity>,
807) -> RunFacts {
808    let entries_verified = stored_state::entries_writable(index);
809    let paired_entries = delivery.cache.writes()
810        && !entries_verified
811        && stored_state::content_tier_writable(index, stored_entries);
812    RunFacts {
813        entries_verified,
814        entries_changed,
815        content_changed,
816        content_requested: basis.content.is_enabled(),
817        projected,
818        paired_entries,
819    }
820}
821
822/// Execute the persistence policy for a live index without retaining a lock while writing.
823#[cfg(feature = "watch")]
824pub(crate) fn persist_index(index: &Index, plan: &Plan) -> Result<bool> {
825    persist_index_changes(index, plan, true, true)
826}
827
828fn persist_index_changes(
829    index: &Index,
830    plan: &Plan,
831    entries_changed: bool,
832    content_changed: bool,
833) -> Result<bool> {
834    let basis = query::Basis::held_by(index);
835    let delivery = plan.delivery();
836    if !delivery.cache.writes() || delivery.cache_path.is_none() {
837        return Ok(false);
838    }
839    let stored = delivery.cache_path.as_deref().map(snapshot::read_header).transpose()?.flatten();
840    // The same admission a load makes, over the header alone: the index's root is the
841    // canonical one the header records, and its scope is the plan's, verified against the
842    // index before any pass mutated it. Whatever the store cannot serve is replaced.
843    let admitted = query::Basis { root: index.root_path().to_path_buf(), ..plan.basis().clone() };
844    let header = stored.as_ref().map(|info| StoreHeader {
845        root: &info.root,
846        snapshot: info.identity,
847        content: None,
848        content_complete: false,
849    });
850    let relation = match plan.admit(header.as_ref(), &admitted) {
851        Admission::Serve(relation) => relation,
852        // The content arm needs a plan that verifies nothing, and no such plan writes.
853        Admission::NoLocation
854        | Admission::Missing
855        | Admission::WrongRoot
856        | Admission::WrongScope
857        | Admission::IncompleteContent => Serves::Refuse,
858    };
859    let projected = relation == Serves::ProjectControlsOff;
860    let writes = plan.writes(run_facts(
861        index,
862        &basis,
863        delivery,
864        entries_changed || relation == Serves::Refuse,
865        content_changed,
866        projected,
867        || {
868            stored
869                .as_ref()
870                .filter(|info| info.root == index.root_path())
871                .map(|info| info.identity.entries)
872        },
873    ));
874    let Some(path) = delivery.cache_path.as_ref() else {
875        return Ok(false);
876    };
877    if writes.metadata {
878        snapshot::save(index, path)?;
879    }
880    if writes.content {
881        content::save_content_cache(index, &content::content_cache_path(path))?;
882    }
883    Ok(writes.metadata)
884}
885
886fn load_content(
887    index: &mut Index,
888    basis: &query::Basis,
889    delivery: &query::Delivery,
890) -> Result<content::ContentCacheLoad> {
891    let (true, Some(snapshot_path)) = (delivery.cache.reads(), delivery.cache_path.as_deref())
892    else {
893        return Ok(content::ContentCacheLoad::default());
894    };
895    let wanted = index.content_identity(basis.content);
896    content::load_content_cache(index, &wanted, &content::content_cache_path(snapshot_path))
897}
898
899/// Start the cache writes a completed open still needs, each tier under its own rule.
900///
901/// The snapshot is written only after a complete pass
902/// ([`stored_state::entries_writable`]): a snapshot recording a partial view would be
903/// served as fact on the next run, and the existing complete snapshot is better than that.
904/// The content sidecar keeps the records the pass verified
905/// ([`stored_state::content_tier_writable`]); a partial pass may replace it only beside a
906/// stored snapshot of the same entry tier.
907fn spawn_save(
908    index: &std::sync::Arc<Index>,
909    delivery: &query::Delivery,
910    writes: SaveTargets,
911    progress: Option<&Progress>,
912) -> PendingSave {
913    let Some(cache_path) = delivery.cache_path.clone().filter(|_| !writes.none()) else {
914        return PendingSave::none();
915    };
916    // Entered here, on the caller's thread, rather than by the writers: a run that
917    // returns with a pending save is saving from the caller's point of view from this
918    // moment, and a poller sees the phase without waiting for a thread to be scheduled.
919    if let Some(progress) = progress {
920        progress.enter(ProgressPhase::Saving);
921    }
922
923    // The index is read-only from here, so the writer and the caller's rendering are two
924    // readers of one index rather than of two copies. This used to deep-clone — every
925    // boxed entry, both stored copies of every name, and every `BTreeMap` — on the
926    // caller's thread, before rendering could start, on every cache-writing run.
927    // Sharing is what buys the independence a clone was buying; a run with nothing to
928    // write still returns above rather than reaching this point.
929    let snapshot_source = std::sync::Arc::clone(index);
930    let mut workers = Vec::with_capacity(2);
931    if writes.metadata {
932        let metadata_source = std::sync::Arc::clone(&snapshot_source);
933        let metadata_path = cache_path.clone();
934        if let Ok(worker) =
935            std::thread::Builder::new().name("fdu-snapshot".to_string()).spawn(move || {
936                let _counter_guard = counters::thread_flush_guard();
937                snapshot::save(&metadata_source, &metadata_path)
938            })
939        {
940            workers.push(("metadata", worker));
941        }
942    }
943    if writes.content {
944        let content_path = content::content_cache_path(&cache_path);
945        if let Ok(worker) =
946            std::thread::Builder::new().name("fdu-content-cache".to_string()).spawn(move || {
947                let _counter_guard = counters::thread_flush_guard();
948                content::save_content_cache(&snapshot_source, &content_path)
949            })
950        {
951            workers.push(("content", worker));
952        }
953    }
954    // A machine that cannot spawn either thread can still answer; it just answers cold
955    // next time.
956    PendingSave { workers }
957}
958
959/// The conventional snapshot location for a root.
960///
961/// Snapshots are keyed by a hash of the canonical root path under the user cache
962/// directory, so two roots never collide and a moved tree simply misses rather than
963/// reading another tree's data.
964pub fn default_cache_path(root: &Path) -> Option<PathBuf> {
965    let canonical = root.canonicalize().ok()?;
966    let mut hash = 0xcbf2_9ce4_8422_2325_u64;
967    for byte in canonical.as_os_str().as_encoded_bytes() {
968        hash ^= u64::from(*byte);
969        hash = hash.wrapping_mul(0x1000_0000_01b3);
970    }
971    Some(user_cache_dir()?.join("fdu").join(cache::snapshot_file_name(hash)))
972}
973
974fn user_cache_dir() -> Option<PathBuf> {
975    if let Some(xdg) = nonempty_env("XDG_CACHE_HOME") {
976        return Some(PathBuf::from(xdg));
977    }
978    platform_cache_dir()
979}
980
981fn nonempty_env(name: &str) -> Option<OsString> {
982    std::env::var_os(name).filter(|value| !value.is_empty())
983}
984
985#[cfg(target_os = "windows")]
986fn platform_cache_dir() -> Option<PathBuf> {
987    windows_cache_dir(
988        nonempty_env("LOCALAPPDATA"),
989        nonempty_env("USERPROFILE"),
990        nonempty_env("HOME"),
991    )
992}
993
994#[cfg(target_os = "windows")]
995fn windows_cache_dir(
996    local_app_data: Option<OsString>,
997    user_profile: Option<OsString>,
998    home: Option<OsString>,
999) -> Option<PathBuf> {
1000    local_app_data
1001        .map(PathBuf::from)
1002        .or_else(|| user_profile.map(|path| PathBuf::from(path).join("AppData").join("Local")))
1003        .or_else(|| home.map(|path| PathBuf::from(path).join(".cache")))
1004}
1005
1006#[cfg(target_os = "macos")]
1007fn platform_cache_dir() -> Option<PathBuf> {
1008    Some(PathBuf::from(nonempty_env("HOME")?).join("Library").join("Caches"))
1009}
1010
1011#[cfg(not(any(target_os = "windows", target_os = "macos")))]
1012fn platform_cache_dir() -> Option<PathBuf> {
1013    Some(PathBuf::from(nonempty_env("HOME")?).join(".cache"))
1014}
1015
1016#[cfg(test)]
1017mod tests {
1018    use super::*;
1019    use std::fs;
1020
1021    fn write_file(path: &Path, contents: &[u8]) {
1022        if let Some(parent) = path.parent() {
1023            fs::create_dir_all(parent).expect("create parent");
1024        }
1025        fs::write(path, contents).expect("write");
1026    }
1027
1028    fn controls_config(
1029        policy: CachePolicy,
1030        snapshot_path: PathBuf,
1031        read_controls: bool,
1032    ) -> OpenFixture {
1033        OpenFixture {
1034            scan: ScanConfig { read_controls, ..ScanConfig::default() },
1035            cache_path: Some(snapshot_path),
1036            policy,
1037            ..OpenFixture::default()
1038        }
1039    }
1040
1041    fn seed_controls_snapshot(root: &Path, snapshot_path: PathBuf) {
1042        let seed = controls_config(CachePolicy::Auto, snapshot_path, true);
1043        let (index, report) = open_fixture(root, &seed).expect("seed controls-on snapshot");
1044        assert_eq!(report.path_taken, OpenPath::ColdScan);
1045        assert!(
1046            !index.controls().expect("control state observed").is_empty(),
1047            "the fixture must retain a control source"
1048        );
1049    }
1050
1051    /// A default `open` observes control state, so its index answers ignore questions
1052    /// exactly. A request that turns observation off reads no control file, so a control
1053    /// line no index could retain does not end it, and its index says it cannot classify
1054    /// ignored entries rather than calling every entry unignored.
1055    #[test]
1056    fn a_default_open_answers_ignore_questions_and_an_opt_out_refuses_them() {
1057        let root = tempfile::tempdir().expect("tempdir");
1058        write_file(&root.path().join(".gitignore"), b"*.log\n");
1059        write_file(&root.path().join("debug.log"), b"ignored");
1060        write_file(&root.path().join("keep.rs"), b"kept");
1061        let uncached = OpenFixture { policy: CachePolicy::Off, ..OpenFixture::default() };
1062        let opted_out = OpenFixture {
1063            scan: ScanConfig { read_controls: false, ..ScanConfig::default() },
1064            ..uncached.clone()
1065        };
1066
1067        let (index, report) = open_fixture(root.path(), &uncached).expect("default open");
1068        assert!(report.is_complete(), "{:?}", report.errors());
1069        assert!(index.observes_controls());
1070        assert_eq!(index.is_ignored(Path::new("debug.log")).ok(), Some(Some(true)));
1071        assert_eq!(index.is_ignored(Path::new("keep.rs")).ok(), Some(Some(false)));
1072        assert_eq!(index.is_ignored(Path::new("absent")).ok(), Some(None));
1073        assert!(
1074            index
1075                .controls()
1076                .is_ok_and(|controls| controls.source_is(Path::new(".gitignore"), b"*.log\n"))
1077        );
1078        assert_eq!(index.partition_total().expect("control state observed").unignored.files, 2);
1079
1080        let mut oversized = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
1081        oversized.extend_from_slice(b"\n*.log\n");
1082        write_file(&root.path().join(".gitignore"), &oversized);
1083        let (index, report) =
1084            open_fixture(root.path(), &uncached).expect("a refused control ends nothing");
1085        assert!(report.is_complete(), "{:?}", report.errors());
1086        let crate::control::ControlCoverage::Observed(coverage) = index.control_coverage() else {
1087            panic!("a default open reads the control file the opt-out skips");
1088        };
1089        assert_eq!((coverage.applied, coverage.refused), (0, 1));
1090
1091        let (index, report) = open_fixture(root.path(), &opted_out)
1092            .expect("an opted-out open reads no control line, however long");
1093        assert!(report.is_complete(), "{:?}", report.errors());
1094        assert!(!index.observes_controls());
1095        for path in ["debug.log", "keep.rs", "absent"] {
1096            assert!(
1097                matches!(index.is_ignored(Path::new(path)), Err(Error::ControlStateNotObserved)),
1098                "{path} must not be called unignored by an index that read no rule"
1099            );
1100        }
1101        assert!(matches!(index.controls(), Err(Error::ControlStateNotObserved)));
1102        assert!(matches!(index.partition_total(), Err(Error::ControlStateNotObserved)));
1103        assert_eq!(index.total().files, 3);
1104    }
1105
1106    /// Lifting a control limit scans cold once, and the snapshot it writes then serves
1107    /// those limits warm, with the coverage it recorded; the other limits' request misses.
1108    #[test]
1109    fn a_snapshot_serves_only_the_control_limits_it_was_taken_under() {
1110        let root = tempfile::tempdir().expect("tempdir");
1111        let cache = tempfile::tempdir().expect("cache dir");
1112        let snapshot_path = cache.path().join("snap.fdu");
1113        let mut long_line = b"*.log\n".to_vec();
1114        long_line.extend(std::iter::repeat_n(b'x', crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1));
1115        write_file(&root.path().join(".gitignore"), &long_line);
1116        write_file(&root.path().join("debug.log"), b"ignored");
1117        let default = controls_config(CachePolicy::Auto, snapshot_path.clone(), true);
1118        let lifted_limits = crate::control::ControlLimits {
1119            line_limit: None,
1120            ..crate::control::ControlLimits::default()
1121        };
1122        let lifted = OpenFixture {
1123            scan: ScanConfig { control_limits: lifted_limits, ..default.scan.clone() },
1124            ..controls_config(CachePolicy::Auto, snapshot_path, true)
1125        };
1126        let refused = |index: &Index| match index.control_coverage() {
1127            crate::control::ControlCoverage::Observed(coverage) => coverage.refused,
1128            crate::control::ControlCoverage::NotObserved => panic!("observed"),
1129        };
1130
1131        let (index, report) = open_fixture(root.path(), &default).expect("default limits");
1132        assert_eq!((report.path_taken, refused(&index)), (OpenPath::ColdScan, 1));
1133        let (index, report) = open_fixture(root.path(), &default).expect("default limits again");
1134        assert_eq!((report.path_taken, refused(&index)), (OpenPath::WarmRevalidate, 1));
1135
1136        let (index, report) = open_fixture(root.path(), &lifted).expect("lifted line limit");
1137        assert_eq!((report.path_taken, refused(&index)), (OpenPath::ColdScan, 0));
1138        assert_eq!(index.is_ignored(Path::new("debug.log")).ok(), Some(Some(true)));
1139        let (index, report) = open_fixture(root.path(), &lifted).expect("lifted line limit again");
1140        assert_eq!((report.path_taken, refused(&index)), (OpenPath::WarmRevalidate, 0));
1141
1142        let (_, report) = open_fixture(root.path(), &default).expect("back to the default");
1143        assert_eq!(report.path_taken, OpenPath::ColdScan);
1144    }
1145
1146    #[test]
1147    fn controls_on_snapshot_projects_to_controls_off_auto_open_without_replacing_it() {
1148        let root = tempfile::tempdir().expect("tempdir");
1149        let cache = tempfile::tempdir().expect("cache dir");
1150        let snapshot_path = cache.path().join("snap.fdu");
1151        write_file(&root.path().join(".gitignore"), b"ignored.log\n");
1152        write_file(&root.path().join("ignored.log"), b"ignored");
1153        seed_controls_snapshot(root.path(), snapshot_path.clone());
1154        let stronger = fs::read(&snapshot_path).expect("stronger snapshot");
1155        write_file(&root.path().join("new.txt"), b"new");
1156
1157        let controls_off = controls_config(CachePolicy::Auto, snapshot_path.clone(), false);
1158        let (index, report) =
1159            open_fixture(root.path(), &controls_off).expect("projected warm open");
1160
1161        assert_eq!(report.path_taken, OpenPath::WarmRevalidate);
1162        assert!(report.projected);
1163        assert_eq!(index.scope(), controls_off.scan.scope());
1164        assert!(matches!(index.controls(), Err(Error::ControlStateNotObserved)));
1165        assert!(matches!(index.path_state(Path::new("new.txt")), PathState::Present { .. }));
1166        assert_eq!(fs::read(snapshot_path).expect("snapshot retained"), stronger);
1167    }
1168
1169    /// A cache-only open refused for the control limits names them and what recovers.
1170    ///
1171    /// Both scopes observe control state and differ only in their ignore-rules identity,
1172    /// which is a hash: without naming the limits, the caller is told "a different scan
1173    /// scope" about a request whose only difference is a limit they chose.
1174    #[test]
1175    fn a_cache_only_open_after_a_limit_change_names_the_limits_that_differ() {
1176        let root = tempfile::tempdir().expect("tempdir");
1177        let cache = tempfile::tempdir().expect("cache dir");
1178        let snapshot_path = cache.path().join("snap.fdu");
1179        write_file(&root.path().join(".gitignore"), b"ignored.log\n");
1180        write_file(&root.path().join("ignored.log"), b"ignored");
1181        seed_controls_snapshot(root.path(), snapshot_path.clone());
1182
1183        let lifted = crate::control::ControlLimits {
1184            line_limit: None,
1185            ..crate::control::ControlLimits::default()
1186        };
1187        let mut wanted = controls_config(CachePolicy::Only, snapshot_path, true);
1188        wanted.scan.control_limits = lifted;
1189        let Err(Error::Snapshot(message)) = open_fixture(root.path(), &wanted) else {
1190            panic!("a snapshot taken under other limits must not serve a cache-only open");
1191        };
1192        assert!(
1193            message.contains("line limit 16 KiB, where this request asks for all"),
1194            "names the limit that differs and both values: {message}"
1195        );
1196        assert!(!message.contains("budget"), "the budget is unchanged: {message}");
1197        assert!(!message.contains("different scan scope"), "says which scope differs: {message}");
1198        assert!(
1199            message.contains("repeat the request with the snapshot's limits"),
1200            "names the remedy: {message}"
1201        );
1202        assert!(message.contains("`auto`"), "names the other remedy: {message}");
1203    }
1204
1205    #[test]
1206    fn controls_on_snapshot_projects_to_controls_off_cache_only_open() {
1207        let root = tempfile::tempdir().expect("tempdir");
1208        let cache = tempfile::tempdir().expect("cache dir");
1209        let snapshot_path = cache.path().join("snap.fdu");
1210        write_file(&root.path().join(".gitignore"), b"ignored.log\n");
1211        write_file(&root.path().join("ignored.log"), b"ignored");
1212        seed_controls_snapshot(root.path(), snapshot_path.clone());
1213
1214        let controls_off = controls_config(CachePolicy::Only, snapshot_path, false);
1215        let (index, report) =
1216            open_fixture(root.path(), &controls_off).expect("projected cache-only open");
1217        assert_eq!(report.path_taken, OpenPath::CacheOnly);
1218        assert!(report.projected);
1219        assert_eq!(index.scope(), controls_off.scan.scope());
1220        assert!(matches!(index.controls(), Err(Error::ControlStateNotObserved)));
1221    }
1222
1223    #[test]
1224    fn cache_only_names_foreign_type_rules_after_validating_the_snapshot_and_root() {
1225        let root = tempfile::tempdir().expect("root");
1226        let other_root = tempfile::tempdir().expect("other root");
1227        let cache = tempfile::tempdir().expect("cache");
1228        let snapshot_path = cache.path().join("snap.fdu");
1229        write_file(&root.path().join("main.rs"), b"fn main() {}\n");
1230        let custom_types = std::sync::Arc::new(
1231            classify::TypeRegistry::from_manifest(
1232                "[[kind]]\nid = \"notes\"\nfamily = \"prose\"\nextensions = [\"rs\"]\n",
1233            )
1234            .expect("custom rules"),
1235        );
1236        let custom = OpenFixture {
1237            scan: ScanConfig::default().with_types(custom_types.clone()),
1238            cache_path: Some(snapshot_path.clone()),
1239            policy: CachePolicy::Auto,
1240            ..OpenFixture::default()
1241        };
1242        open_fixture(root.path(), &custom).expect("write custom snapshot");
1243
1244        let default_only = OpenFixture {
1245            cache_path: Some(snapshot_path.clone()),
1246            policy: CachePolicy::Only,
1247            ..OpenFixture::default()
1248        };
1249        let Err(Error::Snapshot(message)) = open_fixture(root.path(), &default_only) else {
1250            panic!("foreign type rules must not serve cache-only");
1251        };
1252        assert!(message.contains("different file type rules"), "{message}");
1253        assert!(message.contains("type registry") && message.contains("`auto`"), "{message}");
1254        assert!(
1255            snapshot::load(&snapshot_path).expect("direct default-registry load").is_none(),
1256            "a direct load cannot attach the compiled registry to foreign rules"
1257        );
1258        let (_, report) =
1259            open_fixture(root.path(), &OpenFixture { policy: CachePolicy::Only, ..custom.clone() })
1260                .expect("matching registry can use the snapshot");
1261        assert_eq!(report.path_taken, OpenPath::CacheOnly);
1262
1263        let Err(Error::Snapshot(message)) = open_fixture(other_root.path(), &default_only) else {
1264            panic!("a foreign root and rules must not serve cache-only");
1265        };
1266        assert!(message.contains("different root"), "root refusal takes precedence: {message}");
1267        assert!(!message.contains("type rules"), "wrong-root identity must not leak: {message}");
1268
1269        let mut corrupt = fs::read(&snapshot_path).expect("snapshot bytes");
1270        let middle = corrupt.len() / 2;
1271        corrupt[middle] ^= 1;
1272        fs::write(&snapshot_path, corrupt).expect("corrupt payload without updating checksum");
1273        let Err(Error::Snapshot(message)) = open_fixture(root.path(), &default_only) else {
1274            panic!("corrupt snapshot must not yield a type-rules refusal");
1275        };
1276        assert!(!message.contains("type rules"), "corruption is an absent snapshot: {message}");
1277    }
1278
1279    /// Supplied rules reach the answer, and invalidate a snapshot taken under others.
1280    ///
1281    /// The end-to-end property behind the registry: a consumer whose taxonomy differs
1282    /// from this repository's classifies its own way without rebuilding the crate, and a
1283    /// snapshot written under one taxonomy is never served under another. The second half
1284    /// is the one that would fail silently -- the entry counts and byte totals are
1285    /// identical either way, so a stale snapshot looks entirely correct.
1286    #[test]
1287    fn supplied_type_rules_change_the_answer_and_invalidate_the_snapshot() {
1288        let dir = tempfile::tempdir().expect("tempdir");
1289        let cache = tempfile::tempdir().expect("cache dir");
1290        let snapshot_path = cache.path().join("snap.fdu");
1291        write_file(&dir.path().join("main.rs"), b"fn main() {}\n");
1292
1293        let default_config = OpenFixture {
1294            cache_path: Some(snapshot_path.clone()),
1295            analysis: content::AnalysisRequest {
1296                profile: content::AnalysisSet::NONE.with_lines(),
1297                ..content::AnalysisRequest::default()
1298            },
1299            ..OpenFixture::default()
1300        };
1301        let (index, _) = open_fixture(dir.path(), &default_config).expect("default open");
1302        assert_eq!(index.classify(Path::new("main.rs")).file_type.as_str(), "rust");
1303        drop(index);
1304
1305        let mine = std::sync::Arc::new(
1306            classify::TypeRegistry::from_manifest(
1307                "[[kind]]\nid = \"notes\"\nfamily = \"prose\"\nextensions = [\"rs\"]\n",
1308            )
1309            .expect("a minimal manifest"),
1310        );
1311        let custom_config = OpenFixture {
1312            scan: scan::ScanConfig::default().with_types(mine.clone()),
1313            ..default_config.clone()
1314        };
1315
1316        assert_ne!(
1317            custom_config.scan.scope(),
1318            default_config.scan.scope(),
1319            "different rules are a different scan scope"
1320        );
1321
1322        let (index, report) = open_fixture(dir.path(), &custom_config).expect("custom open");
1323        assert_eq!(
1324            report.path_taken,
1325            OpenPath::ColdScan,
1326            "the snapshot was written under other rules and must not be reused"
1327        );
1328        assert_eq!(index.classify(Path::new("main.rs")).file_type.as_str(), "notes");
1329        assert_eq!(index.types().fingerprint(), mine.fingerprint());
1330        let content = index
1331            .content()
1332            .and_then(|content| content.file(Path::new("main.rs")))
1333            .expect("custom analysis record");
1334        assert_eq!(content.detection.file_type.as_str(), "notes");
1335        assert_eq!(
1336            index
1337                .content()
1338                .and_then(content::ContentIndex::provenance)
1339                .expect("content provenance")
1340                .type_rules_fingerprint,
1341            mine.fingerprint()
1342        );
1343
1344        // And the snapshot the custom run wrote is reusable by a run under the same rules.
1345        let (_, report) = open_fixture(dir.path(), &custom_config).expect("second custom open");
1346        assert_eq!(report.path_taken, OpenPath::WarmRevalidate, "same rules, same snapshot");
1347        assert_eq!(report.content_cache.hits, 1, "the matching sidecar is reusable");
1348        assert_eq!(report.analysis.expect("analysis report").candidates, 0);
1349
1350        assert!(
1351            snapshot::load(&snapshot_path).expect("default-registry load").is_none(),
1352            "a direct default-registry load must reject a custom-registry snapshot"
1353        );
1354        let loaded = snapshot::load_with_types(&snapshot_path, mine)
1355            .expect("custom-registry load")
1356            .expect("the matching custom registry makes the snapshot usable");
1357        assert_eq!(loaded.classify(Path::new("main.rs")).file_type.as_str(), "notes");
1358    }
1359
1360    /// The behaviour table from the design, asserted rather than described.
1361    #[test]
1362    fn each_cache_policy_reads_scans_and_writes_as_documented() {
1363        for (policy, expect_write) in [
1364            (CachePolicy::Auto, true),
1365            (CachePolicy::Refresh, true),
1366            (CachePolicy::ReadOnly, false),
1367            (CachePolicy::Off, false),
1368        ] {
1369            let dir = tempfile::tempdir().expect("tempdir");
1370            let cache = tempfile::tempdir().expect("cache dir");
1371            let snapshot_path = cache.path().join("snap.fdu");
1372            write_file(&dir.path().join("a.txt"), b"hello");
1373
1374            let config = OpenFixture {
1375                cache_path: Some(snapshot_path.clone()),
1376                policy,
1377                ..OpenFixture::default()
1378            };
1379            let (index, report) = open_fixture(dir.path(), &config).expect("open");
1380
1381            assert_eq!(index.total().files, 1, "{policy:?} lost an entry");
1382            assert_eq!(report.path_taken, OpenPath::ColdScan, "{policy:?} without a snapshot");
1383            assert_eq!(
1384                snapshot_path.exists(),
1385                expect_write,
1386                "{policy:?} wrote a snapshot: {}",
1387                snapshot_path.exists()
1388            );
1389        }
1390    }
1391
1392    #[test]
1393    fn read_only_takes_the_warm_path_without_rewriting_the_snapshot() {
1394        let dir = tempfile::tempdir().expect("tempdir");
1395        let cache = tempfile::tempdir().expect("cache dir");
1396        let snapshot_path = cache.path().join("snap.fdu");
1397        write_file(&dir.path().join("a.txt"), b"hello");
1398
1399        // Seed a snapshot with auto, then read it without leaving a trace.
1400        let seed = OpenFixture {
1401            cache_path: Some(snapshot_path.clone()),
1402            policy: CachePolicy::Auto,
1403            ..OpenFixture::default()
1404        };
1405        open_fixture(dir.path(), &seed).expect("seed");
1406        let before = fs::metadata(&snapshot_path).expect("snapshot exists").len();
1407
1408        let read_only = OpenFixture { policy: CachePolicy::ReadOnly, ..seed };
1409        let (index, report) = open_fixture(dir.path(), &read_only).expect("warm open");
1410        assert_eq!(report.path_taken, OpenPath::WarmRevalidate);
1411        assert_eq!(index.total().files, 1);
1412        assert_eq!(fs::metadata(&snapshot_path).expect("still there").len(), before);
1413    }
1414
1415    /// A verified warm open over an unchanged tree rewrote a byte-identical snapshot on
1416    /// every run, paying a full index clone, encode, and write to reproduce the file it
1417    /// had just read.  The bytes are the assertion: if a future change makes an
1418    /// unchanged reconciliation produce different serialized state, this fails loudly
1419    /// rather than letting the skip silently drop it.
1420    #[test]
1421    fn an_unchanged_warm_open_leaves_the_snapshot_alone_and_a_changed_one_rewrites_it() {
1422        let dir = tempfile::tempdir().expect("tempdir");
1423        let cache = tempfile::tempdir().expect("cache dir");
1424        let snapshot_path = cache.path().join("snap.fdu");
1425        write_file(&dir.path().join("a.txt"), b"hello");
1426        write_file(&dir.path().join("sub/b.txt"), b"world");
1427
1428        let auto = OpenFixture {
1429            cache_path: Some(snapshot_path.clone()),
1430            policy: CachePolicy::Auto,
1431            ..OpenFixture::default()
1432        };
1433        open_fixture(dir.path(), &auto).expect("seed");
1434        let seeded = fs::read(&snapshot_path).expect("seeded snapshot");
1435
1436        let (index, report) = open_fixture(dir.path(), &auto).expect("warm open");
1437        assert_eq!(report.path_taken, OpenPath::WarmRevalidate);
1438        assert_eq!(index.total().files, 2);
1439        assert_eq!(
1440            fs::read(&snapshot_path).expect("snapshot still there"),
1441            seeded,
1442            "an unchanged warm open must not rewrite the snapshot"
1443        );
1444
1445        write_file(&dir.path().join("sub/c.txt"), b"new file");
1446        let (index, report) = open_fixture(dir.path(), &auto).expect("warm open after a change");
1447        assert_eq!(report.path_taken, OpenPath::WarmRevalidate);
1448        assert_eq!(index.total().files, 3);
1449        let after_add = fs::read(&snapshot_path).expect("rewritten snapshot");
1450        assert_ne!(after_add, seeded, "a warm open that found a new file must persist it");
1451
1452        fs::remove_file(dir.path().join("sub/c.txt")).expect("remove");
1453        let (index, _) = open_fixture(dir.path(), &auto).expect("warm open after a removal");
1454        assert_eq!(index.total().files, 2);
1455        assert_ne!(
1456            fs::read(&snapshot_path).expect("rewritten snapshot"),
1457            after_add,
1458            "a warm open that found a removal must persist it"
1459        );
1460
1461        // The skip must leave a snapshot a later cache-only open can still serve.
1462        let cache_only = OpenFixture { policy: CachePolicy::Only, ..auto };
1463        let (restored, _) = open_fixture(dir.path(), &cache_only).expect("cache-only open");
1464        assert_eq!(restored.total().files, 2);
1465    }
1466
1467    #[test]
1468    fn refresh_ignores_an_existing_snapshot_and_rewrites_it() {
1469        let dir = tempfile::tempdir().expect("tempdir");
1470        let cache = tempfile::tempdir().expect("cache dir");
1471        let snapshot_path = cache.path().join("snap.fdu");
1472        write_file(&dir.path().join("a.txt"), b"hello");
1473
1474        let auto = OpenFixture {
1475            cache_path: Some(snapshot_path.clone()),
1476            policy: CachePolicy::Auto,
1477            ..OpenFixture::default()
1478        };
1479        open_fixture(dir.path(), &auto).expect("seed");
1480
1481        // A second auto open would be warm; refresh must scan cold anyway, which is what
1482        // makes it usable as a benchmark control.
1483        let refresh = OpenFixture { policy: CachePolicy::Refresh, ..auto };
1484        let (_, report) = open_fixture(dir.path(), &refresh).expect("refresh open");
1485        assert_eq!(report.path_taken, OpenPath::ColdScan);
1486        assert!(snapshot_path.exists());
1487    }
1488
1489    #[test]
1490    fn cache_only_answers_from_the_snapshot_without_touching_the_tree() {
1491        let dir = tempfile::tempdir().expect("tempdir");
1492        let cache = tempfile::tempdir().expect("cache dir");
1493        let snapshot_path = cache.path().join("snap.fdu");
1494        write_file(&dir.path().join("a.txt"), b"hello");
1495
1496        let auto = OpenFixture {
1497            cache_path: Some(snapshot_path.clone()),
1498            policy: CachePolicy::Auto,
1499            ..OpenFixture::default()
1500        };
1501        open_fixture(dir.path(), &auto).expect("seed");
1502
1503        // Change the tree after the snapshot was taken. A cache-only answer must report
1504        // what it has, not what is there now — and its freshness must say so.
1505        write_file(&dir.path().join("b.txt"), b"new file");
1506
1507        let only = OpenFixture { policy: CachePolicy::Only, ..auto };
1508        let (index, report) = open_fixture(dir.path(), &only).expect("cache-only open");
1509        assert_eq!(report.path_taken, OpenPath::CacheOnly);
1510        assert_eq!(index.total().files, 1, "the new file must not appear");
1511        assert_ne!(index.freshness(), Freshness::Fresh, "a stale answer must not claim currency");
1512    }
1513
1514    #[test]
1515    fn cache_only_fails_closed_when_no_snapshot_is_usable() {
1516        let dir = tempfile::tempdir().expect("tempdir");
1517        let cache = tempfile::tempdir().expect("cache dir");
1518        write_file(&dir.path().join("a.txt"), b"hello");
1519
1520        // Guessing a scan here would make the fast path unpredictable: sometimes instant,
1521        // sometimes a full walk, with nothing in the output to say which happened.
1522        let only = OpenFixture {
1523            cache_path: Some(cache.path().join("absent.fdu")),
1524            policy: CachePolicy::Only,
1525            ..OpenFixture::default()
1526        };
1527        let Err(Error::Snapshot(message)) = open_fixture(dir.path(), &only) else {
1528            panic!("a cache-only open with no snapshot must fail");
1529        };
1530        // A diagnostic names its remedy: `only` is the one policy that cannot recover, so
1531        // the message says which policy can.
1532        assert!(message.contains("`auto`"), "names the remedy: {message}");
1533    }
1534
1535    #[test]
1536    fn content_sidecar_skips_unchanged_reads_and_serves_cache_only() {
1537        let dir = tempfile::tempdir().expect("tempdir");
1538        let cache = tempfile::tempdir().expect("cache dir");
1539        let snapshot_path = cache.path().join("snap.fdu");
1540        write_file(&dir.path().join("notes.md"), b"one two\n");
1541        let analysis = content::AnalysisRequest {
1542            profile: content::AnalysisSet::NONE.with_lines(),
1543            ..content::AnalysisRequest::default()
1544        };
1545        let auto = OpenFixture {
1546            cache_path: Some(snapshot_path.clone()),
1547            policy: CachePolicy::Auto,
1548            analysis,
1549            ..OpenFixture::default()
1550        };
1551
1552        let (first, first_report) = open_fixture(dir.path(), &auto).expect("cold analyzed open");
1553        assert_eq!(first_report.analysis.expect("analysis").lines.analyzed, 1);
1554        assert_eq!(
1555            first.content_rollup(Path::new("")).expect("content").total.lines.metrics.raw_words,
1556            2
1557        );
1558        assert!(content::content_cache_path(&snapshot_path).exists());
1559
1560        let (_, warm_report) = open_fixture(dir.path(), &auto).expect("warm analyzed open");
1561        assert_eq!(warm_report.content_cache.hits, 1);
1562        assert_eq!(warm_report.content_cache.bytes, 8);
1563        assert_eq!(warm_report.analysis.expect("analysis").candidates, 0);
1564
1565        fs::remove_file(dir.path().join("notes.md")).expect("remove source");
1566        let only = OpenFixture { policy: CachePolicy::Only, ..auto };
1567        let (cached, cached_report) = open_fixture(dir.path(), &only).expect("cache-only content");
1568        assert_eq!(cached_report.content_cache.hits, 1);
1569        assert_eq!(cached_report.content_cache.bytes, 8);
1570        assert_eq!(
1571            cached.content_rollup(Path::new("")).expect("content").total.lines.metrics.raw_words,
1572            2
1573        );
1574    }
1575
1576    #[test]
1577    fn cached_coverage_exclusions_remain_visible_without_making_the_run_partial() {
1578        let dir = tempfile::tempdir().expect("tempdir");
1579        let cache = tempfile::tempdir().expect("cache dir");
1580        let snapshot_path = cache.path().join("snap.fdu");
1581        write_file(&dir.path().join("invalid.txt"), b"valid prefix\xff");
1582        let auto = OpenFixture {
1583            cache_path: Some(snapshot_path),
1584            policy: CachePolicy::Auto,
1585            analysis: content::AnalysisRequest {
1586                profile: content::AnalysisSet::NONE.with_lines(),
1587                ..content::AnalysisRequest::default()
1588            },
1589            ..OpenFixture::default()
1590        };
1591
1592        let (_, cold_report) = open_fixture(dir.path(), &auto).expect("cold analyzed open");
1593        assert!(cold_report.is_complete());
1594        assert_eq!(cold_report.analysis.expect("analysis").lines.invalid_utf8, 1);
1595        assert!(cold_report.error_messages().is_empty());
1596
1597        let (_, warm_report) = open_fixture(dir.path(), &auto).expect("warm analyzed open");
1598        assert_eq!(warm_report.content_cache.hits, 1);
1599        assert_eq!(warm_report.content_cache.coverage_exclusions, 1);
1600        assert_eq!(warm_report.analysis.expect("analysis").candidates, 0);
1601        assert!(warm_report.is_complete());
1602        assert!(warm_report.error_messages().is_empty());
1603
1604        let only = OpenFixture { policy: CachePolicy::Only, ..auto };
1605        let (_, cached_report) = open_fixture(dir.path(), &only).expect("cache-only analyzed open");
1606        assert_eq!(cached_report.content_cache.coverage_exclusions, 1);
1607        assert!(cached_report.is_complete());
1608        assert!(cached_report.error_messages().is_empty());
1609    }
1610
1611    #[test]
1612    fn cache_only_analysis_fails_closed_without_its_sidecar() {
1613        let dir = tempfile::tempdir().expect("tempdir");
1614        let cache = tempfile::tempdir().expect("cache dir");
1615        let snapshot_path = cache.path().join("snap.fdu");
1616        write_file(&dir.path().join("notes.md"), b"one two\n");
1617        let metadata_only = OpenFixture {
1618            cache_path: Some(snapshot_path),
1619            policy: CachePolicy::Auto,
1620            ..OpenFixture::default()
1621        };
1622        open_fixture(dir.path(), &metadata_only).expect("seed metadata");
1623
1624        let only = OpenFixture {
1625            policy: CachePolicy::Only,
1626            analysis: content::AnalysisRequest {
1627                profile: content::AnalysisSet::NONE.with_lines(),
1628                ..content::AnalysisRequest::default()
1629            },
1630            ..metadata_only
1631        };
1632        assert!(matches!(open_fixture(dir.path(), &only), Err(Error::Snapshot(_))));
1633
1634        // A sidecar of another analyzer set is not this request's, whether it is wider or
1635        // narrower: cache-only fails closed rather than answering with the stored set.
1636        let with_set = |policy, profile| OpenFixture {
1637            policy,
1638            analysis: content::AnalysisRequest { profile, ..content::AnalysisRequest::default() },
1639            ..only.clone()
1640        };
1641        let lines = content::AnalysisSet::NONE.with_lines();
1642        for (stored, wanted) in
1643            [(content::AnalysisSet::ALL, lines), (lines, content::AnalysisSet::ALL)]
1644        {
1645            open_fixture(dir.path(), &with_set(CachePolicy::Auto, stored))
1646                .expect("write a sidecar");
1647            let refused = open_fixture(dir.path(), &with_set(CachePolicy::Only, wanted));
1648            assert!(
1649                matches!(refused, Err(Error::Snapshot(_))),
1650                "a {stored:?} sidecar must not serve a cache-only {wanted:?} request"
1651            );
1652        }
1653
1654        open_fixture(dir.path(), &with_set(CachePolicy::Auto, lines))
1655            .expect("write the lines sidecar");
1656        let (cached, report) = open_fixture(dir.path(), &only).expect("restore the analyzed state");
1657        assert!(report.content_cache.usable);
1658        assert_eq!(report.content_cache.hits, 1, "one record per candidate is complete");
1659        assert_eq!(
1660            report.content_cache.candidates, report.content_cache.hits,
1661            "completeness uses the count restore already walked"
1662        );
1663        assert_eq!(cached.content_set(), lines);
1664    }
1665
1666    #[test]
1667    fn cache_only_analysis_fails_closed_when_the_sidecar_is_incomplete() {
1668        let dir = tempfile::tempdir().expect("tempdir");
1669        let cache = tempfile::tempdir().expect("cache dir");
1670        let snapshot_path = cache.path().join("snap.fdu");
1671        write_file(&dir.path().join("notes.md"), b"one two\n");
1672        let analysis = content::AnalysisRequest {
1673            profile: content::AnalysisSet::NONE.with_lines(),
1674            ..content::AnalysisRequest::default()
1675        };
1676        let auto = OpenFixture {
1677            cache_path: Some(snapshot_path),
1678            policy: CachePolicy::Auto,
1679            analysis,
1680            ..OpenFixture::default()
1681        };
1682        open_fixture(dir.path(), &auto).expect("seed one analyzed file");
1683
1684        // A later metadata-only pass widens the snapshot without rewriting the sidecar,
1685        // so cache-only analysis must refuse rather than report a partial content answer.
1686        write_file(&dir.path().join("extra.txt"), b"three\n");
1687        let metadata_only =
1688            OpenFixture { analysis: content::AnalysisRequest::default(), ..auto.clone() };
1689        open_fixture(dir.path(), &metadata_only).expect("widen the snapshot");
1690
1691        let only = OpenFixture { policy: CachePolicy::Only, ..auto };
1692        assert!(
1693            matches!(open_fixture(dir.path(), &only), Err(Error::Snapshot(_))),
1694            "a one-record sidecar must not serve a two-file cache-only analysis request"
1695        );
1696    }
1697
1698    #[cfg(unix)]
1699    #[test]
1700    fn cache_only_serves_checksummed_native_non_utf8_names() {
1701        use std::ffi::OsString;
1702        use std::os::unix::ffi::OsStringExt;
1703
1704        let dir = tempfile::tempdir().expect("tempdir");
1705        let cache = tempfile::tempdir().expect("cache dir");
1706        let snapshot_path = cache.path().join("snap.fdu");
1707        let root = dir.path().canonicalize().expect("canonical root");
1708        let native = PathBuf::from(OsString::from_vec(vec![b'n', 0x80]));
1709        let mut index = Index::new(&root);
1710        index.apply_ok(&Observation::new(vec![
1711            Op::Upsert {
1712                path: PathBuf::from("ok.txt"),
1713                kind: EntryKind::File,
1714                attrs: Attrs {
1715                    size: 1,
1716                    allocated: 512,
1717                    mtime_ns: 1,
1718                    ctime_ns: 1,
1719                    inode: 1,
1720                    dev: 1,
1721                },
1722            },
1723            Op::Upsert {
1724                path: native.clone(),
1725                kind: EntryKind::File,
1726                attrs: Attrs {
1727                    size: 2,
1728                    allocated: 512,
1729                    mtime_ns: 2,
1730                    ctime_ns: 2,
1731                    inode: 2,
1732                    dev: 1,
1733                },
1734            },
1735        ]));
1736        snapshot::save(&index, &snapshot_path).expect("save native names");
1737
1738        let only = OpenFixture {
1739            cache_path: Some(snapshot_path),
1740            policy: CachePolicy::Only,
1741            ..OpenFixture::default()
1742        };
1743        let (cached, report) = open_fixture(&root, &only).expect("cache-only native name");
1744        assert_eq!(cached.total().files, 2);
1745        assert!(cached.lookup(&native).is_some());
1746        assert!(report.is_complete());
1747    }
1748
1749    #[test]
1750    fn cache_only_empty_analysis_still_requires_a_usable_sidecar() {
1751        let dir = tempfile::tempdir().expect("tempdir");
1752        let cache = tempfile::tempdir().expect("cache dir");
1753        let snapshot_path = cache.path().join("snap.fdu");
1754        let metadata_only = OpenFixture {
1755            cache_path: Some(snapshot_path),
1756            policy: CachePolicy::Auto,
1757            ..OpenFixture::default()
1758        };
1759        open_fixture(dir.path(), &metadata_only).expect("seed empty metadata");
1760
1761        let only = OpenFixture {
1762            policy: CachePolicy::Only,
1763            analysis: content::AnalysisRequest {
1764                profile: content::AnalysisSet::NONE.with_lines(),
1765                ..content::AnalysisRequest::default()
1766            },
1767            ..metadata_only
1768        };
1769        assert!(matches!(open_fixture(dir.path(), &only), Err(Error::Snapshot(_))));
1770    }
1771
1772    #[test]
1773    fn a_snapshot_for_another_root_is_treated_as_absent() {
1774        let one = tempfile::tempdir().expect("tempdir");
1775        let two = tempfile::tempdir().expect("tempdir");
1776        let cache = tempfile::tempdir().expect("cache dir");
1777        let snapshot_path = cache.path().join("snap.fdu");
1778        write_file(&one.path().join("a.txt"), b"hello");
1779        write_file(&two.path().join("b.txt"), b"other tree");
1780
1781        let config = OpenFixture {
1782            cache_path: Some(snapshot_path.clone()),
1783            policy: CachePolicy::Auto,
1784            ..OpenFixture::default()
1785        };
1786        open_fixture(one.path(), &config).expect("seed from the first root");
1787
1788        // Reading another tree's snapshot would be worse than a cache miss.
1789        let (index, report) = open_fixture(two.path(), &config).expect("second root");
1790        assert_eq!(report.path_taken, OpenPath::ColdScan);
1791        assert_eq!(index.total().files, 1);
1792        assert_eq!(index.root_path(), two.path().canonicalize().expect("canonical").as_path());
1793    }
1794
1795    #[test]
1796    fn open_without_a_cache_always_scans_cold() {
1797        let dir = tempfile::tempdir().expect("tempdir");
1798        write_file(&dir.path().join("a.txt"), b"hello");
1799
1800        let (index, report) = open_fixture(dir.path(), &OpenFixture::default()).expect("open");
1801        assert_eq!(report.path_taken, OpenPath::ColdScan);
1802        assert_eq!(index.total().files, 1);
1803    }
1804
1805    #[test]
1806    fn second_open_takes_the_warm_path_and_stays_correct() {
1807        let dir = tempfile::tempdir().expect("tempdir");
1808        let cache = tempfile::tempdir().expect("cache dir");
1809        write_file(&dir.path().join("a.txt"), b"hello");
1810        write_file(&dir.path().join("src/main.rs"), b"fn main() {}");
1811
1812        let config = OpenFixture {
1813            cache_path: Some(cache.path().join("snap.fdu")),
1814            policy: CachePolicy::Auto,
1815            ..OpenFixture::default()
1816        };
1817
1818        let (first, first_report) = open_fixture(dir.path(), &config).expect("cold open");
1819        assert_eq!(first_report.path_taken, OpenPath::ColdScan);
1820        assert_eq!(first.total().files, 2);
1821
1822        // Change the tree between opens: the warm path must notice.
1823        write_file(&dir.path().join("added.md"), b"new");
1824        fs::remove_file(dir.path().join("a.txt")).expect("remove");
1825
1826        let (second, second_report) = open_fixture(dir.path(), &config).expect("warm open");
1827        assert_eq!(second_report.path_taken, OpenPath::WarmRevalidate);
1828        assert_eq!(second.total().files, 2);
1829        assert!(second.lookup(Path::new("added.md")).is_some());
1830        assert!(second.lookup(Path::new("a.txt")).is_none());
1831    }
1832
1833    #[test]
1834    fn a_snapshot_from_another_root_is_ignored() {
1835        let a = tempfile::tempdir().expect("tempdir a");
1836        let b = tempfile::tempdir().expect("tempdir b");
1837        let cache = tempfile::tempdir().expect("cache dir");
1838        write_file(&a.path().join("only-in-a.txt"), b"x");
1839        write_file(&b.path().join("only-in-b.txt"), b"y");
1840
1841        let cache_path = cache.path().join("snap.fdu");
1842        let config = OpenFixture {
1843            cache_path: Some(cache_path),
1844            policy: CachePolicy::Auto,
1845            ..OpenFixture::default()
1846        };
1847
1848        open_fixture(a.path(), &config).expect("open a");
1849        let (index, report) = open_fixture(b.path(), &config).expect("open b");
1850
1851        assert_eq!(report.path_taken, OpenPath::ColdScan);
1852        assert!(index.lookup(Path::new("only-in-b.txt")).is_some());
1853        assert!(index.lookup(Path::new("only-in-a.txt")).is_none());
1854    }
1855
1856    #[test]
1857    fn snapshot_scope_mismatch_forces_a_cold_scan() {
1858        let dir = tempfile::tempdir().expect("tempdir");
1859        let cache = tempfile::tempdir().expect("cache dir");
1860        write_file(&dir.path().join("top.txt"), b"top");
1861        write_file(&dir.path().join("deep/nested.txt"), b"nested");
1862
1863        let cache_path = cache.path().join("snap.fdu");
1864        let full = OpenFixture {
1865            cache_path: Some(cache_path.clone()),
1866            policy: CachePolicy::Auto,
1867            ..OpenFixture::default()
1868        };
1869        open_fixture(dir.path(), &full).expect("full open");
1870
1871        let shallow = OpenFixture {
1872            scan: ScanConfig { max_depth: Some(1), ..ScanConfig::default() },
1873            cache_path: Some(cache_path),
1874            policy: CachePolicy::ReadOnly,
1875            analysis: content::AnalysisRequest::default(),
1876        };
1877        let (index, report) = open_fixture(dir.path(), &shallow).expect("shallow open");
1878
1879        assert_eq!(report.path_taken, OpenPath::ColdScan);
1880        assert!(index.lookup(Path::new("deep")).is_some());
1881        assert!(index.lookup(Path::new("deep/nested.txt")).is_none());
1882    }
1883
1884    #[test]
1885    fn admission_scope_mismatch_cannot_reinterpret_a_snapshot() {
1886        let dir = tempfile::tempdir().expect("tempdir");
1887        let cache = tempfile::tempdir().expect("cache dir");
1888        write_file(&dir.path().join(".hidden"), b"hidden");
1889        let cache_path = cache.path().join("snap.fdu");
1890        let seed = OpenFixture {
1891            cache_path: Some(cache_path.clone()),
1892            policy: CachePolicy::Auto,
1893            ..OpenFixture::default()
1894        };
1895        open_fixture(dir.path(), &seed).expect("seed snapshot");
1896
1897        let changed_scopes = [
1898            ScanConfig {
1899                hidden: Some(std::sync::Arc::new(
1900                    HiddenPolicy::prune_hidden::<[&str; 0], &str>([]),
1901                )),
1902                ..ScanConfig::default()
1903            },
1904            ScanConfig { exclude_special: true, ..ScanConfig::default() },
1905        ];
1906        for scan in changed_scopes {
1907            let only = OpenFixture {
1908                scan,
1909                cache_path: Some(cache_path.clone()),
1910                policy: CachePolicy::Only,
1911                analysis: content::AnalysisRequest::default(),
1912            };
1913            assert!(matches!(open_fixture(dir.path(), &only), Err(Error::Snapshot(_))));
1914        }
1915    }
1916
1917    #[test]
1918    fn operational_batch_size_does_not_invalidate_a_snapshot() {
1919        let dir = tempfile::tempdir().expect("tempdir");
1920        let cache = tempfile::tempdir().expect("cache dir");
1921        write_file(&dir.path().join("a.txt"), b"a");
1922        let cache_path = cache.path().join("snap.fdu");
1923
1924        let first = OpenFixture {
1925            scan: ScanConfig { batch_size: 1, ..ScanConfig::default() },
1926            cache_path: Some(cache_path.clone()),
1927            policy: CachePolicy::Auto,
1928            analysis: content::AnalysisRequest::default(),
1929        };
1930        open_fixture(dir.path(), &first).expect("first open");
1931
1932        let second = OpenFixture {
1933            scan: ScanConfig { batch_size: 17, ..ScanConfig::default() },
1934            cache_path: Some(cache_path),
1935            policy: CachePolicy::ReadOnly,
1936            analysis: content::AnalysisRequest::default(),
1937        };
1938        let (_, report) = open_fixture(dir.path(), &second).expect("second open");
1939        assert_eq!(report.path_taken, OpenPath::WarmRevalidate);
1940    }
1941
1942    #[test]
1943    fn cache_paths_differ_per_root() {
1944        let a = tempfile::tempdir().expect("tempdir a");
1945        let b = tempfile::tempdir().expect("tempdir b");
1946        let (Some(pa), Some(pb)) = (default_cache_path(a.path()), default_cache_path(b.path()))
1947        else {
1948            return; // No HOME in this environment; nothing to assert.
1949        };
1950        assert_ne!(pa, pb);
1951        assert_eq!(pa, default_cache_path(a.path()).expect("stable"));
1952    }
1953
1954    #[cfg(target_os = "windows")]
1955    #[test]
1956    fn windows_cache_discovery_prefers_native_locations() {
1957        let local = OsString::from(r"C:\Users\tester\AppData\Local");
1958        let profile = OsString::from(r"D:\Profile");
1959        let home = OsString::from(r"E:\Home");
1960
1961        assert_eq!(
1962            windows_cache_dir(Some(local.clone()), Some(profile.clone()), Some(home.clone())),
1963            Some(PathBuf::from(local))
1964        );
1965        assert_eq!(
1966            windows_cache_dir(None, Some(profile.clone()), Some(home.clone())),
1967            Some(PathBuf::from(profile).join("AppData").join("Local"))
1968        );
1969        assert_eq!(
1970            windows_cache_dir(None, None, Some(home.clone())),
1971            Some(PathBuf::from(home).join(".cache"))
1972        );
1973        assert_eq!(windows_cache_dir(None, None, None), None);
1974    }
1975}
1976
1977#[cfg(test)]
1978mod save_tests {
1979    use super::*;
1980    use std::fs;
1981
1982    fn write_file(path: &Path, contents: &[u8]) {
1983        if let Some(parent) = path.parent() {
1984            fs::create_dir_all(parent).expect("create parent");
1985        }
1986        fs::write(path, contents).expect("write");
1987    }
1988
1989    fn config(snapshot_path: &Path, policy: CachePolicy) -> OpenFixture {
1990        OpenFixture {
1991            cache_path: Some(snapshot_path.to_path_buf()),
1992            policy,
1993            ..OpenFixture::default()
1994        }
1995    }
1996
1997    #[test]
1998    fn a_pending_save_completes_when_joined() {
1999        let dir = tempfile::tempdir().expect("tempdir");
2000        let cache = tempfile::tempdir().expect("cache dir");
2001        let snapshot_path = cache.path().join("snap.fdu");
2002        write_file(&dir.path().join("a.txt"), b"hello");
2003
2004        let (_index, _report, pending) =
2005            open_fixture_with_pending_save(dir.path(), &config(&snapshot_path, CachePolicy::Auto))
2006                .expect("open");
2007        pending.join().expect("save succeeds");
2008        assert!(snapshot_path.exists(), "a joined save must have landed");
2009    }
2010
2011    #[test]
2012    fn a_dropped_save_still_lands() {
2013        // Dropping without joining is a caller mistake, not a reason to lose the write:
2014        // the next run would otherwise pay for a cold scan this one already did.
2015        let dir = tempfile::tempdir().expect("tempdir");
2016        let cache = tempfile::tempdir().expect("cache dir");
2017        let snapshot_path = cache.path().join("snap.fdu");
2018        write_file(&dir.path().join("a.txt"), b"hello");
2019
2020        {
2021            let (_index, _report, _pending) = open_fixture_with_pending_save(
2022                dir.path(),
2023                &config(&snapshot_path, CachePolicy::Auto),
2024            )
2025            .expect("open");
2026        }
2027        assert!(snapshot_path.exists());
2028    }
2029
2030    #[test]
2031    #[cfg(unix)]
2032    fn a_partial_scan_leaves_the_previous_snapshot_alone() {
2033        // Writing a partial view would serve it as fact on the next run, and the
2034        // existing complete snapshot is better than that.
2035        use std::os::unix::fs::PermissionsExt;
2036
2037        if !crate::test_support::require_permission_bits() {
2038            return;
2039        }
2040
2041        let dir = tempfile::tempdir().expect("tempdir");
2042        let cache = tempfile::tempdir().expect("cache dir");
2043        let snapshot_path = cache.path().join("snap.fdu");
2044        write_file(&dir.path().join("a.txt"), b"hello");
2045
2046        let settings = config(&snapshot_path, CachePolicy::Auto);
2047        open_fixture(dir.path(), &settings).expect("seed a complete snapshot");
2048        let complete_len = fs::metadata(&snapshot_path).expect("exists").len();
2049
2050        let denied = dir.path().join("denied");
2051        fs::create_dir(&denied).expect("create");
2052        write_file(&denied.join("hidden.txt"), b"hidden");
2053        fs::set_permissions(&denied, fs::Permissions::from_mode(0o000)).expect("deny");
2054
2055        let opened = open_fixture(dir.path(), &settings);
2056        fs::set_permissions(&denied, fs::Permissions::from_mode(0o700)).expect("restore");
2057        let (_index, report) = opened.expect("partial open still returns a result");
2058
2059        assert!(!report.is_complete(), "the scan should be partial");
2060        assert_eq!(
2061            fs::metadata(&snapshot_path).expect("still there").len(),
2062            complete_len,
2063            "a partial scan must not overwrite a complete snapshot"
2064        );
2065    }
2066
2067    /// Each tier is written by its own rule: a partial pass writes no snapshot, because an
2068    /// absent entry would change totals, but may write every verified content record beside
2069    /// a stored snapshot of the same entry identity. It still writes nothing under another
2070    /// identity, where replacing the sidecar would separate it from the snapshot it names.
2071    #[test]
2072    #[cfg(unix)]
2073    fn a_partial_scan_writes_only_verified_content_for_the_stored_entry_tier() {
2074        use std::os::unix::fs::PermissionsExt;
2075
2076        if !crate::test_support::require_permission_bits() {
2077            return;
2078        }
2079
2080        let dir = tempfile::tempdir().expect("tempdir");
2081        let cache = tempfile::tempdir().expect("cache dir");
2082        let snapshot_path = cache.path().join("snap.fdu");
2083        let sidecar_path = content::content_cache_path(&snapshot_path);
2084        write_file(&dir.path().join("notes.md"), b"one two\n");
2085        write_file(&dir.path().join("locked/old.md"), b"three\n");
2086        let analysis = content::AnalysisRequest {
2087            profile: content::AnalysisSet::NONE.with_lines(),
2088            ..content::AnalysisRequest::default()
2089        };
2090        let auto = OpenFixture {
2091            cache_path: Some(snapshot_path.clone()),
2092            policy: CachePolicy::Auto,
2093            analysis,
2094            ..OpenFixture::default()
2095        };
2096        let (_, seeded) = open_fixture(dir.path(), &auto).expect("seed both tiers");
2097        assert!(seeded.is_complete());
2098        let snapshot_before = fs::read(&snapshot_path).expect("a snapshot");
2099
2100        // Change a file the next pass can verify, and lock the directory holding another.
2101        write_file(&dir.path().join("notes.md"), b"one two three\n");
2102        let locked = dir.path().join("locked");
2103        let run = |config: &OpenFixture| {
2104            fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).expect("deny");
2105            let opened = open_fixture(dir.path(), config);
2106            fs::set_permissions(&locked, fs::Permissions::from_mode(0o700)).expect("restore");
2107            let (_, report) = opened.expect("a partial open still answers");
2108            assert!(!report.is_complete(), "the pass should be partial");
2109        };
2110
2111        // Under another entry identity: neither tier is written.
2112        let sidecar_before = fs::read(&sidecar_path).expect("a sidecar");
2113        let other_scope = OpenFixture {
2114            scan: ScanConfig { max_depth: Some(8), ..ScanConfig::default() },
2115            ..auto.clone()
2116        };
2117        run(&other_scope);
2118        assert_eq!(fs::read(&snapshot_path).expect("snapshot"), snapshot_before);
2119        assert_eq!(
2120            fs::read(&sidecar_path).expect("sidecar"),
2121            sidecar_before,
2122            "a partial run under another entry identity must not evict the paired sidecar"
2123        );
2124
2125        // Under the stored snapshot's identity, the snapshot stays whole while the sidecar
2126        // keeps only content whose entry facts this pass verified.
2127        run(&auto);
2128        assert_eq!(
2129            fs::read(&snapshot_path).expect("snapshot"),
2130            snapshot_before,
2131            "a partial scan must not overwrite a complete snapshot"
2132        );
2133        assert_ne!(fs::read(&sidecar_path).expect("sidecar"), sidecar_before);
2134        // The verified changed file is reusable; no record survives under the directory
2135        // this pass could not list.
2136        let (mut fresh, _) =
2137            scan::scan_into_index(dir.path(), &ScanConfig::default()).expect("scan");
2138        let wanted = fresh.content_identity(analysis.profile);
2139        let loaded = content::load_content_cache(&mut fresh, &wanted, &sidecar_path).expect("load");
2140        assert_eq!((loaded.usable, loaded.hits, loaded.stale), (true, 1, 0), "{loaded:?}");
2141        let content = fresh.content().expect("content");
2142        assert!(content.file(Path::new("locked/old.md")).is_none());
2143        assert_eq!(
2144            content
2145                .file(Path::new("notes.md"))
2146                .expect("verified record")
2147                .lines
2148                .value()
2149                .expect("line metrics")
2150                .raw_words,
2151            3
2152        );
2153    }
2154
2155    /// A warm partial pass keeps every record whose entry the pass verified, including
2156    /// unchanged files, and removes records below an unlistable directory.
2157    #[test]
2158    #[cfg(unix)]
2159    fn a_warm_partial_pass_keeps_all_and_only_verified_content_records() {
2160        use std::os::unix::fs::PermissionsExt;
2161
2162        if !crate::test_support::require_permission_bits() {
2163            return;
2164        }
2165
2166        let dir = tempfile::tempdir().expect("tempdir");
2167        let cache = tempfile::tempdir().expect("cache dir");
2168        let snapshot_path = cache.path().join("snap.fdu");
2169        let sidecar_path = content::content_cache_path(&snapshot_path);
2170        for index in 0..7 {
2171            write_file(&dir.path().join(format!("f{index}.md")), b"alpha\nbeta\n");
2172        }
2173        write_file(&dir.path().join("locked/old.md"), b"three\n");
2174        let analysis = content::AnalysisRequest {
2175            profile: content::AnalysisSet::NONE.with_lines(),
2176            ..content::AnalysisRequest::default()
2177        };
2178        let auto = OpenFixture {
2179            cache_path: Some(snapshot_path.clone()),
2180            policy: CachePolicy::Auto,
2181            analysis,
2182            ..OpenFixture::default()
2183        };
2184
2185        let records_in_sidecar = |path: &Path| {
2186            let (mut fresh, _) = scan::scan_into_index(path, &ScanConfig::default()).expect("scan");
2187            let wanted = fresh.content_identity(analysis.profile);
2188            let loaded =
2189                content::load_content_cache(&mut fresh, &wanted, &sidecar_path).expect("load");
2190            assert!(loaded.usable, "the sidecar should still be readable");
2191            loaded.hits + loaded.stale
2192        };
2193
2194        let (_, seeded) = open_fixture(dir.path(), &auto).expect("seed both tiers");
2195        assert!(seeded.is_complete());
2196        assert_eq!(records_in_sidecar(dir.path()), 8, "one record per seeded file");
2197
2198        // One file changes, one directory becomes unlistable: the warm pass is partial.
2199        write_file(&dir.path().join("f0.md"), b"alpha\nbeta\ngamma\n");
2200        let locked = dir.path().join("locked");
2201        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).expect("deny");
2202        let opened = open_fixture(dir.path(), &auto);
2203        fs::set_permissions(&locked, fs::Permissions::from_mode(0o700)).expect("restore");
2204        let (_, report) = opened.expect("a partial open still answers");
2205        assert!(!report.is_complete(), "the pass should be partial");
2206
2207        assert_eq!(
2208            records_in_sidecar(dir.path()),
2209            7,
2210            "all seven verified files survive and the inaccessible record does not"
2211        );
2212    }
2213
2214    #[test]
2215    fn no_snapshot_is_written_when_the_policy_forbids_it() {
2216        let dir = tempfile::tempdir().expect("tempdir");
2217        let cache = tempfile::tempdir().expect("cache dir");
2218        let snapshot_path = cache.path().join("snap.fdu");
2219        write_file(&dir.path().join("a.txt"), b"hello");
2220
2221        for policy in [CachePolicy::ReadOnly, CachePolicy::Off] {
2222            let (_index, _report, pending) =
2223                open_fixture_with_pending_save(dir.path(), &config(&snapshot_path, policy))
2224                    .expect("open");
2225            pending.join().expect("nothing to join");
2226            assert!(!snapshot_path.exists(), "{policy:?} wrote a snapshot");
2227        }
2228    }
2229}