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