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