Skip to main content

fdu_core/
execution.rs

1//! Planning and executing one-shot reports with the least retained state they require.
2//!
3//! The command surface stays composable: callers describe cache policy and a query, not
4//! an implementation strategy.  This module derives that strategy.  Most reports need
5//! the complete [`Index`](crate::Index), either because another view needs hierarchy or
6//! paths, or because the cache must retain reusable state.  An unfiltered summary that
7//! observes no `.gitignore` needs only five aggregate values, so that one plan reduces the
8//! scan's observations directly and never builds an index.
9
10use std::time::SystemTime;
11
12use crate::query::{
13    Delivery, Report, ReportProvenance, ReportSource, Request, SummaryRow, TreeStatus, ViewSpec,
14    report, report_summary,
15};
16use crate::{CachePolicy, EntryKind, Error, OpenPath, PendingSave, Progress, Result, execute};
17
18/// The minimum state a one-shot report plan retains while scanning.
19///
20/// This is deliberately a small closed set.  Add another tier only when a measured view
21/// can be answered exactly from materially less state than an index; callers should not
22/// have to select it themselves.
23#[derive(Clone, Copy, PartialEq, Eq, Debug)]
24pub(crate) enum RetainedState {
25    /// One aggregate row; no path or hierarchy records survive the scan.
26    ///
27    /// Without hierarchy or a control table this tier cannot tell whether an entry is
28    /// ignored, so a scan that observes control state never selects it.
29    Summary,
30    /// The complete reusable metadata index.
31    FullIndex,
32}
33
34/// The engine lifecycle that will deliver an answer.
35#[derive(Clone, Copy, PartialEq, Eq, Debug)]
36pub enum Route {
37    /// A single report may retain only an aggregate.
38    OneShot,
39    /// A reusable index returned to the caller.
40    Retained,
41    /// Verification of an index already held by the caller.
42    Refresh,
43    /// A continuing observation session.
44    Watch,
45    /// Progressive discovery and serving of an opened root.
46    Opened,
47}
48
49/// Which persisted state execution may read.
50#[derive(Clone, Copy, PartialEq, Eq, Debug)]
51pub enum Load {
52    /// Start without a metadata snapshot.
53    None,
54    /// Attempt to restore a serving metadata snapshot.
55    Snapshot,
56}
57
58/// Whether execution must verify the filesystem.
59#[derive(Clone, Copy, PartialEq, Eq, Debug)]
60pub enum Verify {
61    /// Answer only from persisted facts, marked unverified.
62    None,
63    /// Observe the requested filesystem scope.
64    Filesystem,
65}
66
67/// Whether an answer fulfills the caller's delivery contract.
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69pub enum OutcomeClass {
70    /// A complete answer, or a partial answer the caller explicitly accepts.
71    Success,
72    /// An incomplete answer the caller did not accept.
73    Partial,
74}
75
76/// Validated policy shared by all engine execution routes.
77#[derive(Clone, Debug)]
78pub struct Plan {
79    pub(crate) basis: crate::query::Basis,
80    pub(crate) route: Route,
81    pub(crate) retained: RetainedState,
82    pub(crate) load: Load,
83    pub(crate) verify: Verify,
84    pub(crate) delivery: Delivery,
85}
86
87impl Plan {
88    /// Classify the answer using the caller's partial-answer policy.
89    pub fn outcome(&self, status: &TreeStatus) -> OutcomeClass {
90        if status.complete || self.delivery.accept_partial {
91            OutcomeClass::Success
92        } else {
93            OutcomeClass::Partial
94        }
95    }
96    /// The semantic basis validated when the plan was constructed.
97    pub fn basis(&self) -> &crate::query::Basis {
98        &self.basis
99    }
100    /// The lifecycle this plan executes.
101    pub const fn route(&self) -> Route {
102        self.route
103    }
104    /// The persisted state this plan may read.
105    pub const fn load(&self) -> Load {
106        self.load
107    }
108    /// The verification this plan performs.
109    pub const fn verify(&self) -> Verify {
110        self.verify
111    }
112    /// The caller's operational choices after validation.
113    pub fn delivery(&self) -> &Delivery {
114        &self.delivery
115    }
116}
117
118/// Stored tier declarations and restoration evidence presented to a plan.
119pub(crate) struct StoreHeader<'a> {
120    pub(crate) root: &'a std::path::Path,
121    pub(crate) snapshot: crate::SnapshotIdentity,
122    pub(crate) content: Option<&'a crate::ContentTierIdentity>,
123    pub(crate) content_complete: bool,
124}
125
126/// Why persisted state cannot deliver a planned answer.
127#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128pub(crate) enum Admission {
129    Serve(crate::Serves),
130    NoLocation,
131    Missing,
132    WrongRoot,
133    WrongScope,
134    IncompleteContent,
135}
136
137impl Plan {
138    /// The one decision of whether stored state answers this plan's basis.
139    ///
140    /// Every route that reads a snapshot admits it here, warm and cache-only alike, and
141    /// persistence asks the same question of the header on disk before it decides what an
142    /// unchanged pass owes the store. The content arm applies only to a plan that verifies
143    /// nothing, because a verifying route re-reads what its sidecar lacks.
144    pub(crate) fn admit(
145        &self,
146        stored: Option<&StoreHeader<'_>>,
147        basis: &crate::query::Basis,
148    ) -> Admission {
149        if self.delivery.cache_path.is_none() {
150            return Admission::NoLocation;
151        }
152        let Some(stored) = stored else {
153            return Admission::Missing;
154        };
155        if stored.root != basis.root {
156            return Admission::WrongRoot;
157        }
158        let relation = crate::serves_snapshot(stored.snapshot, basis.scope.snapshot_identity());
159        if relation == crate::Serves::Refuse {
160            return Admission::WrongScope;
161        }
162        if self.verify == Verify::None && basis.content.is_enabled() {
163            let wanted = crate::ContentTierIdentity::for_request(
164                basis.scope.snapshot_identity().entries,
165                basis.content,
166            );
167            if !stored.content_complete
168                || stored.content.and_then(|identity| wanted.admit(identity)).is_none()
169            {
170                return Admission::IncompleteContent;
171            }
172        }
173        Admission::Serve(relation)
174    }
175}
176
177/// Facts observed by execution, independent of the route that observed them.
178#[derive(Clone, Copy, Debug)]
179#[allow(clippy::struct_excessive_bools)]
180pub(crate) struct RunFacts {
181    pub(crate) entries_verified: bool,
182    pub(crate) entries_changed: bool,
183    pub(crate) content_changed: bool,
184    pub(crate) content_requested: bool,
185    pub(crate) projected: bool,
186    pub(crate) paired_entries: bool,
187}
188
189/// Artifacts the plan authorizes its executor to write.
190#[derive(Clone, Copy, Debug, PartialEq, Eq)]
191pub(crate) struct SaveTargets {
192    pub(crate) metadata: bool,
193    pub(crate) content: bool,
194}
195
196impl SaveTargets {
197    pub(crate) const fn none(self) -> bool {
198        !self.metadata && !self.content
199    }
200}
201
202impl Plan {
203    pub(crate) fn writes(&self, run: RunFacts) -> SaveTargets {
204        let allowed = self.delivery.cache.writes() && self.delivery.cache_path.is_some();
205        SaveTargets {
206            metadata: allowed && run.entries_verified && run.entries_changed && !run.projected,
207            content: allowed
208                && run.content_requested
209                && run.content_changed
210                && (run.entries_verified || run.paired_entries),
211        }
212    }
213}
214
215/// Operational work behind one one-shot report.
216///
217/// This is deliberately separate from [`Report`]: it is transient CLI telemetry, not
218/// part of the stable machine-report schema or the pure query result.
219#[derive(Clone, Copy, PartialEq, Eq, Debug)]
220pub struct PerformanceSummary {
221    /// Regular files whose metadata was observed during this run.
222    pub walked_files: u64,
223    /// Apparent bytes represented by those walked files.
224    pub walked_bytes: u64,
225    /// Allocated bytes of those walked files, the figure to show beside an answer
226    /// measured in allocated bytes.
227    pub walked_allocated: u64,
228    /// Fresh content-analysis candidates processed.
229    pub fresh_files: u64,
230    /// Bytes actually returned by fresh content reads.
231    pub bytes_read: u64,
232    /// Wall time spent processing fresh analysis candidates.
233    pub analysis_ns: u64,
234    /// Content-analysis records restored from the sidecar.
235    pub cached_files: u64,
236    /// Apparent bytes represented by restored content records.
237    pub cached_bytes: u64,
238    /// Metadata cache tier used for this report.
239    pub source: ReportSource,
240}
241
242impl Default for PerformanceSummary {
243    fn default() -> Self {
244        Self {
245            walked_files: 0,
246            walked_bytes: 0,
247            walked_allocated: 0,
248            fresh_files: 0,
249            bytes_read: 0,
250            analysis_ns: 0,
251            cached_files: 0,
252            cached_bytes: 0,
253            source: ReportSource::ColdScan,
254        }
255    }
256}
257
258impl PerformanceSummary {
259    fn from_open_report(report: &crate::OpenReport) -> Self {
260        let analysis = report.analysis.unwrap_or_default();
261        Self {
262            walked_files: report.scan.files_walked,
263            walked_bytes: report.scan.bytes_walked,
264            walked_allocated: report.scan.allocated_walked,
265            fresh_files: analysis.candidates,
266            bytes_read: analysis.bytes_read,
267            analysis_ns: analysis.elapsed_ns,
268            cached_files: report.content_cache.hits,
269            cached_bytes: report.content_cache.bytes,
270            source: match report.path_taken {
271                OpenPath::ColdScan => ReportSource::ColdScan,
272                OpenPath::WarmRevalidate => ReportSource::WarmRevalidate,
273                OpenPath::CacheOnly => ReportSource::CacheOnly,
274            },
275        }
276    }
277}
278
279/// Validate a request and derive the least-retention plan for its delivery and route.
280///
281/// A summary reducer is legal when no content analysis is requested, the sole requested
282/// view is an unfiltered summary, the scan observes no control state, and the policy does
283/// not require the snapshot to participate.  [`crate::open`] and live sessions still
284/// promise an index and therefore always plan full retention. Any future requirement the
285/// compact tier cannot prove falls closed to `RetainedState::FullIndex`.
286///
287/// Control observation is the caller's decision, not this planner's: a report's rows carry
288/// the ignored share of every size they show (fdu-elnn), so a scan that reads `.gitignore`
289/// displays what it paid for, and one that turned it off shows no share rather than a zero.
290/// The summary reducer keeps no table to classify with, so an observing summary falls
291/// closed to the index. That trades the reducer's small footprint for the ignored share in
292/// the default `fdu --view summary`; the performance ledger records what it costs.
293///
294/// The compact tier is not gated on the cache being unavailable, because for an
295/// unfiltered metadata summary the snapshot cannot save the work the scan is doing.
296/// Revalidating a loaded snapshot stats every entry anyway, so the reusable index and its
297/// write are additive cost with nothing to amortise them: measured on Linux/ext4 over
298/// 84,539 entries, the compact tier answered in 71 ms against 161 ms for a warm
299/// revalidating `Auto` run, and even a no-scan `Only` read cost 81 ms because
300/// deserialisation is about as expensive per record as a warm walk.  A snapshot earns its
301/// keep when it avoids expensive work — re-reading file bodies for content analysis, or a
302/// cold filesystem walk — not when it merely mirrors a walk that still has to happen.
303///
304/// Two policies still require the index, for reasons that are about intent rather than
305/// cost.  [`CachePolicy::Only`] must answer from the snapshot without touching the tree,
306/// so it has no scan to reduce.  [`CachePolicy::Refresh`] is an explicit request to
307/// rewrite the snapshot, and honouring it means materialising the index that gets
308/// written — though with no cache path configured there is nothing to rewrite, and the
309/// compact tier answers it like any other summary.
310pub fn plan(
311    request: &Request,
312    delivery: &Delivery,
313    route: Route,
314) -> std::result::Result<Plan, crate::query::RequestError> {
315    request.validate()?;
316    let mut normalized = delivery.clone();
317    if route == Route::Watch {
318        normalized.watch.get_or_insert_with(crate::query::WatchDelivery::default);
319    }
320    let delivery = &normalized;
321    request.validate_delivery(delivery)?;
322    if route == Route::Opened {
323        if delivery.cache != CachePolicy::Off
324            || delivery.watch.is_some()
325            || request.basis.content.is_enabled()
326        {
327            return Err(crate::query::RequestError::DeliveryUnsupported {
328                route: "opened",
329                reason: "progressive discovery requires cache off, no content analyzers, and observation configured through OpenOptions",
330            });
331        }
332        // An opened root runs one breadth-first producer and publishes coverage as state
333        // rather than as one answer, so these fields have no effect there. Refused rather
334        // than dropped: a delivery the route accepts is one it executes.
335        if delivery.workers.scan.is_some()
336            || delivery.order != crate::ScanOrder::default()
337            || delivery.accept_partial
338        {
339            return Err(crate::query::RequestError::DeliveryUnsupported {
340                route: "opened",
341                reason: "progressive discovery schedules one breadth-first producer and reports coverage as state, so it takes no scan worker count, traversal order, or partial-answer acceptance",
342            });
343        }
344    }
345    if route == Route::Refresh && delivery.cache == CachePolicy::Only {
346        return Err(crate::query::RequestError::DeliveryUnsupported {
347            route: "refresh",
348            reason: "the only cache policy cannot verify filesystem state",
349        });
350    }
351    let analysis_requested = request.basis.content.is_enabled();
352    let summary_is_sufficient = request.query.views.as_slice() == [ViewSpec::Summary]
353        && request.query.selection.is_unfiltered()
354        && !request.basis.scope.read_controls;
355    let policy_requires_index = match delivery.cache {
356        CachePolicy::Only => true,
357        CachePolicy::Refresh => delivery.cache_path.is_some(),
358        CachePolicy::Off | CachePolicy::Auto | CachePolicy::ReadOnly => false,
359    };
360    // A one-shot metadata query cannot amortize loading and reconciling a snapshot:
361    // both paths stat every entry. On macOS/APFS (494,031 entries), warm revalidation
362    // cost 4.8 s versus 3.6 s cold, while the write it might avoid cost only ~50 ms.
363    // Content avoids body reads, and retained routes amortize their reusable index.
364    let read_snapshot = match delivery.cache {
365        CachePolicy::Only => true,
366        CachePolicy::Off | CachePolicy::Refresh => false,
367        CachePolicy::Auto | CachePolicy::ReadOnly => route != Route::OneShot || analysis_requested,
368    };
369    Ok(Plan {
370        basis: request.basis.clone(),
371        route,
372        retained: if route == Route::OneShot
373            && !policy_requires_index
374            && !analysis_requested
375            && summary_is_sufficient
376        {
377            RetainedState::Summary
378        } else {
379            RetainedState::FullIndex
380        },
381        load: if read_snapshot { Load::Snapshot } else { Load::None },
382        verify: if delivery.cache == CachePolicy::Only { Verify::None } else { Verify::Filesystem },
383        delivery: delivery.clone(),
384    })
385}
386
387/// Execute a one-shot report, retaining the least state the request needs.
388///
389/// The returned report is complete as a value even while the optional save runs.  The
390/// caller must join the handle before exit; dropping it also joins defensively.
391///
392/// This is the contract the command line has always run under, and until now the only way
393/// to get it was to be the command line. `open` takes the session path: it retains an
394/// index and writes a snapshot, which is right for a caller asking many questions and
395/// wrong for one asking a single question -- an unfiltered summary that reads no
396/// `.gitignore` is answered by a transient tier that retains nothing, and writing a
397/// snapshot for it caches state the walk did not save. A Python caller therefore left
398/// cache state on a tree that the same command would not have, which a later cache-only
399/// read could see (fdu-4msv).
400///
401/// The report observes `.gitignore` control state as the request's
402/// [`ScanConfig::read_controls`](crate::ScanConfig) says, on by default as for
403/// [`crate::open`], so the two share one snapshot scope. Observing,
404/// every tree, summary, extension, and file row carries its ignored share, and
405/// [`Report::ignore_rules`](crate::query::Report::ignore_rules) names any file a control
406/// limit refused, by the budget or by the line limit. Turned off, no `.gitignore` is
407/// read, every share is `None`, and a selection by ignored state is refused with
408/// [`Error::InvalidRequest`] before anything is scanned. Such a report reads a default
409/// snapshot under every reading policy by constructing a requested-scope index from its
410/// all-entry facts and discarding its classification.
411///
412/// The caller owns the returned [`PendingSave`] and decides when to join it, exactly as
413/// the command line does, so a renderer can run while the snapshot is still being written.
414pub fn prepare_report(
415    request: &Request,
416    delivery: &Delivery,
417) -> Result<(Report, PendingSave, PerformanceSummary)> {
418    prepare_report_internal(request, delivery, false, None)
419        .map(|(report, pending, performance, _diagnostics)| (report, pending, performance))
420}
421
422/// Execute a one-shot report, reporting its progress through `progress` as it runs.
423///
424/// The same contract and the same answer as [`prepare_report`]: the handle observes the
425/// run and changes nothing about it, so a report prepared with one is byte-for-byte the
426/// report prepared without. The caller polls [`Progress::snapshot`] from another thread
427/// while this blocks, typically to draw a wait indicator. Which phases the run passes
428/// through, what the counters mean, and what holds when this returns are documented on
429/// [`Progress`]; in short, the walk counters equal the returned
430/// [`PerformanceSummary`]'s walked totals, and a run that requested content analysis
431/// leaves `analysis` at `(fresh_files, fresh_files)`.
432///
433/// A run over a full index ends in [`ProgressPhase::Summarizing`](crate::ProgressPhase)
434/// while it builds the answer; a save it started continues in the background, and the
435/// caller decides when to join it, as with [`prepare_report`]. `Saving` is therefore
436/// shown only for the moment between the save's start and the answer's, however long
437/// the write takes.
438pub fn prepare_report_with_progress(
439    request: &Request,
440    delivery: &Delivery,
441    progress: &Progress,
442) -> Result<(Report, PendingSave, PerformanceSummary)> {
443    prepare_report_internal(request, delivery, false, Some(progress))
444        .map(|(report, pending, performance, _diagnostics)| (report, pending, performance))
445}
446
447/// Execute a one-shot report and retain scan diagnostics.
448///
449/// Public because the command line needs it and the command line is an ordinary consumer:
450/// it drives repository-controlled measurement of the installed binary. Kept separate
451/// from [`prepare_report`] so callers who do not want traces pay for neither collection
452/// nor serialization. The diagnostic value is present only when the report performs a
453/// cold scan; cache-only opens do not scan, and warm reconciliation has a different
454/// execution contract.
455pub fn prepare_report_with_scan_diagnostics(
456    request: &Request,
457    delivery: &Delivery,
458) -> Result<(Report, PendingSave, PerformanceSummary, Option<crate::scan::ScanDiagnostics>)> {
459    prepare_report_internal(request, delivery, true, None)
460}
461
462fn prepare_report_internal(
463    request: &Request,
464    delivery: &Delivery,
465    collect_scan_diagnostics: bool,
466    progress: Option<&Progress>,
467) -> Result<(Report, PendingSave, PerformanceSummary, Option<crate::scan::ScanDiagnostics>)> {
468    // Before anything is scanned, loaded, or reduced: a request its own basis cannot answer
469    // has no answer at any cost, and the compact summary tier below never reaches a reader,
470    // so a check made there would not cover this route at all. A scope this build cannot
471    // honour is part of that one check rather than a second one beside it, which is what
472    // keeps the refusal independent of the delivery: the cache-only tier never scans and
473    // the cold tier never loads, so a rule stated at either would hold for one of them.
474    request.validate().map_err(Error::InvalidRequest)?;
475    let scan_config = crate::ScanConfig {
476        progress: progress.cloned(),
477        ..request.basis.scope.scan_config(delivery)
478    };
479    let root = request.basis.root.as_path();
480    let scan_started_at = SystemTime::now();
481    let plan = plan(request, delivery, Route::OneShot).map_err(Error::InvalidRequest)?;
482    match plan.retained {
483        RetainedState::Summary => {
484            let root = root.canonicalize().map_err(|error| Error::io(root, error))?;
485            let mut summary = SummaryRow::default();
486            let mut reduce = |observed: &crate::ObservationOp| {
487                let crate::Op::Upsert { kind, attrs, .. } = &observed.op else {
488                    return;
489                };
490                match kind {
491                    EntryKind::File => {
492                        summary.files += 1;
493                        summary.bytes += attrs.size;
494                        summary.allocated += attrs.allocated;
495                        summary.newest_mtime_ns = Some(
496                            summary
497                                .newest_mtime_ns
498                                .map_or(attrs.mtime_ns, |current| current.max(attrs.mtime_ns)),
499                        );
500                    }
501                    EntryKind::Dir => summary.dirs += 1,
502                    EntryKind::Symlink | EntryKind::Other => {}
503                }
504            };
505            let (mut scan, scan_diagnostics) = if collect_scan_diagnostics {
506                let (scan, diagnostics) = crate::scan::scan_summary_fold_with_diagnostics(
507                    &root,
508                    &scan_config,
509                    &mut reduce,
510                )?;
511                (scan, Some(diagnostics))
512            } else {
513                (crate::scan::scan_summary_fold(&root, &scan_config, &mut reduce)?, None)
514            };
515            let complete = scan.is_complete();
516            let generated_at = SystemTime::now();
517            let report = report_summary(
518                &root,
519                scan_config.scope(),
520                request,
521                summary,
522                TreeStatus::of_walk(&root, &mut scan),
523                ReportProvenance::of_walk(scan_started_at, generated_at, complete),
524            );
525            let performance = PerformanceSummary {
526                walked_files: scan.files_walked,
527                walked_bytes: scan.bytes_walked,
528                walked_allocated: scan.allocated_walked,
529                source: ReportSource::ColdScan,
530                ..PerformanceSummary::default()
531            };
532            Ok((report, PendingSave::none(), performance, scan_diagnostics))
533        }
534        RetainedState::FullIndex => {
535            let (index, open_report, pending_save, scan_diagnostics) =
536                execute(&plan, &request.basis, collect_scan_diagnostics, progress)?;
537            let performance = PerformanceSummary::from_open_report(&open_report);
538            if let Some(progress) = progress {
539                progress.enter(crate::ProgressPhase::Summarizing);
540            }
541            let answer = report(&index, request, SystemTime::now())?;
542            debug_assert_eq!(answer.scope, scan_config.scope());
543            Ok((answer, pending_save, performance, scan_diagnostics))
544        }
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use std::fs;
551    use std::path::{Path, PathBuf};
552
553    use super::*;
554    use crate::query::{IgnoredEntries, Pattern, Query, Section};
555    use crate::{OpenFixture, ScanConfig};
556
557    #[test]
558    #[cfg(unix)]
559    fn an_unreadable_stored_header_never_authorizes_live_replacement() {
560        use std::os::unix::fs::PermissionsExt;
561        if !crate::test_support::require_permission_bits() {
562            return;
563        }
564        let root = tempfile::tempdir().expect("root");
565        let cache = tempfile::tempdir().expect("cache");
566        let snapshot = cache.path().join("snapshot.fdu");
567        fs::write(root.path().join(".gitignore"), b"ignored\n").expect("control");
568        let observed = crate::query::Basis {
569            root: root.path().into(),
570            scope: crate::query::Scope::default(),
571            content: crate::content::AnalysisSet::NONE,
572        };
573        let delivery = Delivery::new(CachePolicy::Auto, Some(snapshot.clone()));
574        crate::open(&observed, &delivery).expect("stronger snapshot");
575        let original = fs::read(&snapshot).expect("original image");
576        let basis = crate::query::Basis {
577            scope: crate::query::Scope { read_controls: false, ..Default::default() },
578            ..observed
579        };
580        let (mut index, _) =
581            crate::open(&basis, &Delivery::new(CachePolicy::Off, None)).expect("fresh blind index");
582        let request = Request::new(basis, Query::default(), SystemTime::now());
583        let plan = plan(&request, &delivery, Route::Refresh).expect("plan");
584        fs::set_permissions(&snapshot, fs::Permissions::from_mode(0o000))
585            .expect("deny header read");
586        for policy in [CachePolicy::Off, CachePolicy::ReadOnly] {
587            let nonwriting = Delivery { cache: policy, ..delivery.clone() };
588            let nonwriting_plan =
589                super::plan(&request, &nonwriting, Route::Refresh).expect("nonwriting plan");
590            assert!(
591                !crate::persist_index_changes(&index, &nonwriting_plan, true, true)
592                    .expect("nonwriting policy never reads the header")
593            );
594        }
595        fs::write(root.path().join("fresh.txt"), b"fresh").expect("mutation");
596        crate::refresh(
597            &mut index,
598            &request.basis,
599            &Delivery { cache: CachePolicy::Off, ..delivery.clone() },
600        )
601        .expect("off refresh does not inspect cache state");
602        let result = crate::persist_index_changes(&index, &plan, true, true);
603        fs::set_permissions(&snapshot, fs::Permissions::from_mode(0o600)).expect("restore");
604        assert!(
605            result.is_err(),
606            "a writable parent must not let unknown identity authorize replacement"
607        );
608        assert_eq!(fs::read(&snapshot).expect("retained image"), original);
609    }
610
611    #[test]
612    fn refresh_rejects_another_root_before_mutating_or_persisting() {
613        let a = tempfile::tempdir().expect("root a");
614        let b = tempfile::tempdir().expect("root b");
615        let cache = tempfile::tempdir().expect("cache");
616        let basis = crate::query::Basis {
617            root: a.path().into(),
618            scope: crate::query::Scope::default(),
619            content: crate::content::AnalysisSet::NONE,
620        };
621        let (mut index, _) =
622            crate::open(&basis, &Delivery::new(CachePolicy::Off, None)).expect("open a");
623        fs::write(a.path().join("new"), b"new facts").expect("mutation a");
624        let before = index.clock();
625        let snapshot = cache.path().join("snapshot.fdu");
626        let wrong = crate::query::Basis { root: b.path().into(), ..basis.clone() };
627        let delivery = Delivery::new(CachePolicy::Auto, Some(snapshot.clone()));
628        let error = crate::refresh(&mut index, &wrong, &delivery).expect_err("different root");
629        assert!(matches!(
630            error,
631            Error::InvalidRequest(crate::query::RequestError::RootMismatch { .. })
632        ));
633        assert_eq!(index.clock(), before);
634        assert!(!snapshot.exists());
635        let alias = crate::query::Basis { root: a.path().join("."), ..basis };
636        crate::refresh(&mut index, &alias, &delivery).expect("same root spelling");
637        assert_eq!(index.total().files, 1);
638        #[cfg(unix)]
639        {
640            let link = cache.path().join("root-alias");
641            std::os::unix::fs::symlink(a.path(), &link).expect("root alias");
642            let symlink_basis = crate::query::Basis { root: link, ..alias };
643            crate::refresh(&mut index, &symlink_basis, &delivery)
644                .expect("same canonical root through symlink");
645        }
646    }
647
648    #[test]
649    fn unchanged_refresh_replaces_an_incompatible_stored_baseline() {
650        let root = tempfile::tempdir().expect("root");
651        let other = tempfile::tempdir().expect("other root");
652        let cache = tempfile::tempdir().expect("cache");
653        fs::write(root.path().join("file"), b"retained").expect("file");
654        let basis = crate::query::Basis {
655            root: root.path().into(),
656            scope: crate::query::Scope::default(),
657            content: crate::content::AnalysisSet::NONE,
658        };
659        let delivery = Delivery::new(CachePolicy::Auto, Some(cache.path().join("snapshot.fdu")));
660        for wrong_root in [false, true] {
661            let wrong = if wrong_root {
662                crate::query::Basis { root: other.path().into(), ..basis.clone() }
663            } else {
664                crate::query::Basis {
665                    scope: crate::query::Scope { max_depth: Some(0), ..basis.scope.clone() },
666                    ..basis.clone()
667                }
668            };
669            crate::open(&wrong, &Delivery { cache: CachePolicy::Refresh, ..delivery.clone() })
670                .expect("incompatible snapshot");
671            let (mut index, _) = crate::open(&basis, &Delivery::new(CachePolicy::Off, None))
672                .expect("retained index");
673            let refreshed =
674                crate::refresh(&mut index, &basis, &delivery).expect("refresh reseeds cache");
675            assert!(!refreshed.apply.mutated(), "the existing index was already current");
676            let (cached, _) =
677                crate::open(&basis, &Delivery { cache: CachePolicy::Only, ..delivery.clone() })
678                    .expect("cache-only can now answer");
679            assert_eq!(cached.total().bytes, 8);
680        }
681    }
682
683    #[test]
684    fn refreshed_metadata_and_content_are_visible_to_a_later_cache_only_open() {
685        let root = tempfile::tempdir().expect("root");
686        let cache = tempfile::tempdir().expect("cache");
687        let path = root.path().join("note.txt");
688        fs::write(&path, b"old\n").expect("old file");
689        let basis = crate::query::Basis {
690            root: root.path().into(),
691            scope: crate::query::Scope::default(),
692            content: crate::content::AnalysisSet::NONE.with_lines(),
693        };
694        let delivery = Delivery::new(CachePolicy::Auto, Some(cache.path().join("snapshot.fdu")));
695        let (mut index, _) = crate::open(&basis, &delivery).expect("initial open");
696        fs::write(&path, b"new longer text\nsecond line\n").expect("mutation");
697        let refreshed = crate::refresh(&mut index, &basis, &delivery).expect("refresh");
698        assert!(refreshed.is_complete());
699        let (cached, report) =
700            crate::open(&basis, &Delivery { cache: CachePolicy::Only, ..delivery })
701                .expect("cache-only sees refreshed tiers");
702        assert_eq!(report.path_taken, OpenPath::CacheOnly);
703        assert_eq!(cached.total(), index.total());
704        assert_eq!(
705            cached.total().bytes,
706            u64::try_from(b"new longer text\nsecond line\n".len()).expect("length")
707        );
708        assert_eq!(report.content_cache.hits, 1);
709        let original = index
710            .content()
711            .expect("fresh content")
712            .file(Path::new("note.txt"))
713            .expect("fresh file");
714        let restored = cached
715            .content()
716            .expect("restored content")
717            .file(Path::new("note.txt"))
718            .expect("restored file");
719        assert_eq!(restored, original);
720    }
721
722    /// One root with one file and one empty directory, and a writing delivery whose
723    /// snapshot lives in its own directory so a test can make that directory read-only.
724    #[cfg(unix)]
725    fn owed_persistence_fixture()
726    -> (tempfile::TempDir, tempfile::TempDir, crate::query::Basis, Delivery) {
727        let root = tempfile::tempdir().expect("root");
728        let cache = tempfile::tempdir().expect("cache");
729        fs::create_dir(root.path().join("locked")).expect("locked dir");
730        fs::write(root.path().join("first"), b"first").expect("first file");
731        let basis = crate::query::Basis {
732            root: root.path().into(),
733            scope: crate::query::Scope::default(),
734            content: crate::content::AnalysisSet::NONE,
735        };
736        let delivery = Delivery::new(CachePolicy::Auto, Some(cache.path().join("snapshot.fdu")));
737        (root, cache, basis, delivery)
738    }
739
740    /// The files a cache-only open of `delivery`'s snapshot answers with.
741    #[cfg(unix)]
742    fn cached_files(basis: &crate::query::Basis, delivery: &Delivery) -> u64 {
743        let cache_only = Delivery { cache: CachePolicy::Only, ..delivery.clone() };
744        crate::open(basis, &cache_only).expect("cache-only open").0.total().files
745    }
746
747    /// A refresh whose metadata write failed leaves the index holding facts the snapshot
748    /// lacks; the next refresh must write them even though it changes nothing itself.
749    ///
750    /// The metadata write used to be keyed to the pass that ran it: a later pass that
751    /// mutated nothing wrote nothing, so a snapshot that missed one write missed the
752    /// facts for good, and cache-only reads answered older facts than the index held.
753    #[test]
754    #[cfg(unix)]
755    fn an_unchanged_refresh_repeats_the_metadata_write_a_failed_refresh_owed() {
756        use std::os::unix::fs::PermissionsExt;
757        if !crate::test_support::require_permission_bits() {
758            return;
759        }
760        let (root, cache, basis, delivery) = owed_persistence_fixture();
761        let (mut index, _) = crate::open(&basis, &delivery).expect("initial open");
762        assert_eq!(cached_files(&basis, &delivery), 1);
763
764        fs::write(root.path().join("second"), b"second").expect("second file");
765        fs::set_permissions(cache.path(), fs::Permissions::from_mode(0o555)).expect("deny write");
766        let failed = crate::refresh(&mut index, &basis, &delivery);
767        fs::set_permissions(cache.path(), fs::Permissions::from_mode(0o755)).expect("restore");
768        assert!(failed.is_err(), "a read-only cache directory fails the write");
769        assert_eq!(index.total().files, 2, "the index advanced before the write");
770        assert_eq!(cached_files(&basis, &delivery), 1, "the failed write left the old image");
771
772        let unchanged = crate::refresh(&mut index, &basis, &delivery).expect("unchanged refresh");
773        assert!(unchanged.is_complete());
774        assert!(!unchanged.apply.mutated(), "nothing changed between the passes");
775        assert_eq!(cached_files(&basis, &delivery), 2, "the owed write ran");
776
777        // Paid once: the next unchanged pass has nothing to write.
778        let snapshot = delivery.cache_path.as_deref().expect("path");
779        let written = fs::metadata(snapshot).expect("snapshot").modified().expect("mtime");
780        crate::refresh(&mut index, &basis, &delivery).expect("settled refresh");
781        assert_eq!(fs::metadata(snapshot).expect("snapshot").modified().expect("mtime"), written);
782    }
783
784    /// The same debt when the failed write is the one a warm `open` started: a caller
785    /// keeping the index through [`crate::open_with_pending_save`] keeps the debt too.
786    #[test]
787    #[cfg(unix)]
788    fn an_unchanged_refresh_repeats_the_metadata_write_a_failed_open_owed() {
789        use std::os::unix::fs::PermissionsExt;
790        if !crate::test_support::require_permission_bits() {
791            return;
792        }
793        let (root, cache, basis, delivery) = owed_persistence_fixture();
794        crate::open(&basis, &delivery).expect("complete open writes the snapshot");
795        fs::write(root.path().join("second"), b"second").expect("second file");
796        fs::set_permissions(cache.path(), fs::Permissions::from_mode(0o555)).expect("deny write");
797        let opened = crate::open_with_pending_save(&basis, &delivery);
798        // Joined before the directory is writable again: the write runs in the background.
799        let outcome = opened.map(|(index, report, pending)| (index, report, pending.join()));
800        fs::set_permissions(cache.path(), fs::Permissions::from_mode(0o755)).expect("restore");
801        let (index, report, joined) = outcome.expect("the open itself succeeds");
802        assert_eq!(report.path_taken, OpenPath::WarmRevalidate);
803        assert!(joined.is_err(), "the startup write failed");
804        let mut index = std::sync::Arc::into_inner(index).expect("the writer released the index");
805        assert_eq!(cached_files(&basis, &delivery), 1);
806
807        let unchanged = crate::refresh(&mut index, &basis, &delivery).expect("unchanged refresh");
808        assert!(!unchanged.apply.mutated(), "nothing changed between the passes");
809        assert_eq!(cached_files(&basis, &delivery), 2, "the owed write ran");
810    }
811
812    /// A partial refresh cannot write the entry tier; once the tree is readable again a
813    /// complete refresh delivers the partial pass's facts to the snapshot.
814    ///
815    /// Restoring the directory's permissions updates its change time, so on a POSIX host
816    /// the recovering pass reports that directory as updated and would write on its own
817    /// account. The failed-write tests above are the ones that prove the debt is carried;
818    /// this one guards that a partial pass's verified facts reach the snapshot at all.
819    #[test]
820    #[cfg(unix)]
821    fn a_complete_refresh_persists_the_facts_a_partial_refresh_could_not() {
822        use std::os::unix::fs::PermissionsExt;
823        if !crate::test_support::require_permission_bits() {
824            return;
825        }
826        let (root, _cache, basis, delivery) = owed_persistence_fixture();
827        let locked = root.path().join("locked");
828        let (mut index, _) = crate::open(&basis, &delivery).expect("initial open");
829        assert_eq!(index.total().files, 1);
830
831        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).expect("deny read");
832        fs::write(root.path().join("second"), b"second").expect("second file");
833        let partial = crate::refresh(&mut index, &basis, &delivery);
834        fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).expect("restore");
835        let partial = partial.expect("partial refresh");
836        assert!(!partial.is_complete(), "the locked directory made the pass partial");
837        assert!(partial.apply.mutated(), "the second file was inserted");
838        assert_eq!(index.total().files, 2);
839        assert_eq!(cached_files(&basis, &delivery), 1, "a partial pass never writes entries");
840
841        let complete = crate::refresh(&mut index, &basis, &delivery).expect("complete refresh");
842        assert!(complete.is_complete(), "{:?}", complete.scan.errors);
843        assert_eq!(cached_files(&basis, &delivery), 2, "the complete pass wrote the facts");
844    }
845
846    #[test]
847    fn cache_only_refusals_name_location_root_and_absence_separately() {
848        let root = tempfile::tempdir().expect("root");
849        let other = tempfile::tempdir().expect("other root");
850        let cache = tempfile::tempdir().expect("cache");
851        let basis = crate::query::Basis {
852            root: root.path().into(),
853            scope: crate::query::Scope::default(),
854            content: crate::content::AnalysisSet::NONE,
855        };
856        let snapshot = cache.path().join("snapshot.fdu");
857        let message = |delivery: &Delivery| {
858            crate::open(&basis, delivery).expect_err("cache-only refusal").to_string()
859        };
860        let no_location = message(&Delivery::new(CachePolicy::Only, None));
861        assert!(no_location.contains("no cache location"), "{no_location}");
862        assert!(!no_location.contains("use auto"), "no write can succeed without a location");
863        let missing = message(&Delivery::new(CachePolicy::Only, Some(snapshot.clone())));
864        assert!(missing.contains("no usable snapshot"), "{missing}");
865        assert!(missing.contains("auto"), "{missing}");
866        let other_basis = crate::query::Basis { root: other.path().into(), ..basis.clone() };
867        crate::open(&other_basis, &Delivery::new(CachePolicy::Auto, Some(snapshot.clone())))
868            .expect("other snapshot");
869        let wrong_root = message(&Delivery::new(CachePolicy::Only, Some(snapshot)));
870        assert!(wrong_root.contains("different root"), "{wrong_root}");
871    }
872
873    #[test]
874    fn route_delivery_matrix_rejects_contracts_the_route_cannot_execute() {
875        let basis = crate::query::Basis {
876            root: ".".into(),
877            scope: crate::query::Scope::default(),
878            content: crate::content::AnalysisSet::NONE,
879        };
880        let request = Request::new(basis, Query::default(), SystemTime::now());
881        for delivery in Delivery::enumerate() {
882            for route in
883                [Route::OneShot, Route::Retained, Route::Refresh, Route::Watch, Route::Opened]
884            {
885                let result = plan(&request, &delivery, route);
886                let forbidden = (delivery.watch.is_some()
887                    || matches!(route, Route::Watch | Route::Refresh))
888                    && delivery.cache == CachePolicy::Only
889                    || route == Route::Opened
890                        && (delivery.cache != CachePolicy::Off
891                            || delivery.watch.is_some()
892                            || delivery.accept_partial);
893                assert_eq!(result.is_err(), forbidden, "{route:?} {delivery:?}");
894                if let Ok(plan) = result {
895                    if route == Route::Opened {
896                        assert_eq!(plan.load(), Load::None);
897                        assert_eq!(plan.verify(), Verify::Filesystem);
898                    }
899                }
900            }
901        }
902    }
903
904    #[test]
905    fn an_opened_root_refuses_the_scheduling_it_would_otherwise_drop() {
906        // `OpenOptions::into_parts` runs one breadth-first producer whatever the delivery
907        // says, and an opened root has no single answer for `accept_partial` to classify.
908        // A value the route would silently ignore is refused at planning instead, and the
909        // same values plan on a route that executes them.
910        let basis = crate::query::Basis {
911            root: ".".into(),
912            scope: crate::query::Scope::default(),
913            content: crate::content::AnalysisSet::NONE,
914        };
915        let request = Request::new(basis, Query::default(), SystemTime::now());
916        let default = Delivery::new(CachePolicy::Off, None);
917        let plan_opened = plan(&request, &default, Route::Opened).expect("defaults plan");
918        assert_eq!(plan_opened.delivery().batch_size, default.batch_size);
919        let unhonored = [
920            (
921                "scan workers",
922                Delivery {
923                    workers: crate::query::Workers { scan: Some(4), ..default.workers },
924                    ..default.clone()
925                },
926            ),
927            (
928                "depth-first order",
929                Delivery { order: crate::ScanOrder::DepthFirst, ..default.clone() },
930            ),
931            ("accept partial", Delivery { accept_partial: true, ..default.clone() }),
932        ];
933        for (case, delivery) in unhonored {
934            let refused = plan(&request, &delivery, Route::Opened).expect_err(case);
935            assert!(
936                matches!(
937                    refused,
938                    crate::query::RequestError::DeliveryUnsupported { route: "opened", .. }
939                ),
940                "{case}: {refused}"
941            );
942            plan(&request, &delivery, Route::Retained)
943                .unwrap_or_else(|error| panic!("{case} executes on a retained route: {error}"));
944        }
945        // A larger batch is honored, so it is not refused.
946        let batched = Delivery { batch_size: default.batch_size * 2, ..default };
947        let plan_batched = plan(&request, &batched, Route::Opened).expect("batch size plans");
948        assert_eq!(plan_batched.delivery().batch_size, batched.batch_size);
949    }
950
951    #[test]
952    fn write_policy_depends_only_on_delivery_and_observed_facts() {
953        let routes = [Route::OneShot, Route::Retained, Route::Refresh, Route::Watch, Route::Opened];
954        for delivery in Delivery::enumerate() {
955            for bits in 0_u8..64 {
956                let facts = RunFacts {
957                    entries_verified: bits & 1 != 0,
958                    entries_changed: bits & 2 != 0,
959                    content_changed: bits & 4 != 0,
960                    content_requested: bits & 8 != 0,
961                    projected: bits & 16 != 0,
962                    paired_entries: bits & 32 != 0,
963                };
964                let allowed = delivery.cache.writes();
965                let expected = SaveTargets {
966                    metadata: allowed
967                        && facts.entries_verified
968                        && facts.entries_changed
969                        && !facts.projected,
970                    content: allowed
971                        && facts.content_requested
972                        && facts.content_changed
973                        && (facts.entries_verified || facts.paired_entries),
974                };
975                for route in routes {
976                    let plan = Plan {
977                        basis: crate::query::Basis {
978                            root: ".".into(),
979                            scope: crate::query::Scope::default(),
980                            content: crate::content::AnalysisSet::NONE,
981                        },
982                        route,
983                        retained: RetainedState::FullIndex,
984                        load: Load::Snapshot,
985                        verify: Verify::Filesystem,
986                        delivery: delivery.clone(),
987                    };
988                    assert_eq!(plan.writes(facts), expected, "{route:?} {delivery:?} {facts:?}");
989                    let unavailable = Plan {
990                        delivery: Delivery { cache_path: None, ..delivery.clone() },
991                        ..plan
992                    };
993                    assert!(unavailable.writes(facts).none());
994                }
995            }
996        }
997    }
998
999    fn planned(config: &OpenFixture, query: &Query) -> Plan {
1000        let (request, delivery) = split(Path::new("."), config, query);
1001        plan(&request, &delivery, Route::OneShot).expect("valid plan")
1002    }
1003
1004    fn summary_query() -> Query {
1005        Query { views: vec![ViewSpec::Summary], ..Query::default() }
1006    }
1007
1008    /// The request and the delivery a test's `OpenFixture` spells, split the way the two
1009    /// models now divide it: what the answer says, and how it is carried out.
1010    fn split(root: &Path, config: &OpenFixture, query: &Query) -> (Request, Delivery) {
1011        let (basis, delivery) = config.split(root);
1012        (Request::new(basis, query.clone(), std::time::UNIX_EPOCH), delivery)
1013    }
1014
1015    /// [`prepare_report`] as these tests ask for it: one configuration, one query.
1016    fn prepared(
1017        root: &Path,
1018        config: &OpenFixture,
1019        query: &Query,
1020    ) -> Result<(Report, PendingSave, PerformanceSummary)> {
1021        let (request, delivery) = split(root, config, query);
1022        prepare_report(&request, &delivery)
1023    }
1024
1025    /// [`prepared`], keeping the scan diagnostics.
1026    fn prepared_with_diagnostics(
1027        root: &Path,
1028        config: &OpenFixture,
1029        query: &Query,
1030    ) -> Result<(Report, PendingSave, PerformanceSummary, Option<crate::scan::ScanDiagnostics>)>
1031    {
1032        let (request, delivery) = split(root, config, query);
1033        prepare_report_with_scan_diagnostics(&request, &delivery)
1034    }
1035
1036    fn config(policy: CachePolicy, cache_path: Option<PathBuf>) -> OpenFixture {
1037        OpenFixture { scan: ScanConfig::default(), cache_path, policy, ..OpenFixture::default() }
1038    }
1039
1040    /// [`config`] with `.gitignore` observation turned off, the one scan the compact
1041    /// summary tier can answer.
1042    fn blind(policy: CachePolicy, cache_path: Option<PathBuf>) -> OpenFixture {
1043        OpenFixture {
1044            scan: ScanConfig { read_controls: false, ..ScanConfig::default() },
1045            ..config(policy, cache_path)
1046        }
1047    }
1048
1049    fn controls_config(
1050        policy: CachePolicy,
1051        cache_path: PathBuf,
1052        read_controls: bool,
1053    ) -> OpenFixture {
1054        OpenFixture {
1055            scan: ScanConfig { read_controls, ..ScanConfig::default() },
1056            cache_path: Some(cache_path),
1057            policy,
1058            ..OpenFixture::default()
1059        }
1060    }
1061
1062    fn seed_controls_snapshot(root: &Path, cache_path: PathBuf) {
1063        fs::write(root.join(".gitignore"), b"ignored.log\n").expect("control file");
1064        fs::write(root.join("ignored.log"), b"ignored").expect("ignored file");
1065        crate::open_fixture(root, &controls_config(CachePolicy::Auto, cache_path, true))
1066            .expect("seed controls-on snapshot");
1067    }
1068
1069    #[test]
1070    fn planner_uses_compact_state_only_when_the_request_proves_it_is_sufficient() {
1071        let off = blind(CachePolicy::Off, Some(PathBuf::from("unused.fdu")));
1072        assert_eq!(planned(&off, &summary_query()).retained, RetainedState::Summary);
1073
1074        for policy in [CachePolicy::Auto, CachePolicy::Refresh, CachePolicy::ReadOnly] {
1075            let unavailable = blind(policy, None);
1076            assert_eq!(planned(&unavailable, &summary_query()).retained, RetainedState::Summary);
1077        }
1078
1079        let mut several_views = summary_query();
1080        several_views.views.push(ViewSpec::Types);
1081        assert_eq!(planned(&off, &several_views).retained, RetainedState::FullIndex);
1082
1083        let mut filtered = summary_query();
1084        filtered.selection.include.push(Pattern::parse("*.rs").expect("pattern"));
1085        assert_eq!(planned(&off, &filtered).retained, RetainedState::FullIndex);
1086
1087        // The reducer keeps no control table, so a summary whose row carries an ignored
1088        // share, or selects by one, needs the index.
1089        let observing = config(CachePolicy::Off, None);
1090        assert!(observing.scan.read_controls, "observation is the default");
1091        assert_eq!(planned(&observing, &summary_query()).retained, RetainedState::FullIndex);
1092        let mut by_ignored = summary_query();
1093        by_ignored.selection.ignored = IgnoredEntries::Exclude;
1094        assert_eq!(planned(&observing, &by_ignored).retained, RetainedState::FullIndex);
1095    }
1096
1097    #[test]
1098    fn an_available_snapshot_does_not_force_the_index_for_a_metadata_summary() {
1099        // A loaded snapshot cannot save the work an unfiltered metadata summary is
1100        // already doing: revalidation stats every entry regardless, so retaining the
1101        // index and writing it back is additive cost with nothing to amortise it. The
1102        // compact tier stays selected so the common one-shot totals request pays for a
1103        // walk and nothing else.
1104        for policy in [CachePolicy::Auto, CachePolicy::ReadOnly] {
1105            let cached = blind(policy, Some(PathBuf::from("cache.fdu")));
1106            assert_eq!(
1107                planned(&cached, &summary_query()).retained,
1108                RetainedState::Summary,
1109                "{policy:?} must not be forced onto the index by a present snapshot"
1110            );
1111        }
1112    }
1113
1114    #[test]
1115    fn policies_whose_intent_is_the_snapshot_itself_still_retain_the_index() {
1116        // These two are not cost decisions. `Only` must answer without touching the tree,
1117        // so it has no scan to reduce; `Refresh` is an explicit request to rewrite the
1118        // snapshot, which means materialising the index that gets written.
1119        for policy in [CachePolicy::Only, CachePolicy::Refresh] {
1120            let cached = config(policy, Some(PathBuf::from("cache.fdu")));
1121            assert_eq!(
1122                planned(&cached, &summary_query()).retained,
1123                RetainedState::FullIndex,
1124                "{policy:?} needs the index to honour its contract"
1125            );
1126        }
1127    }
1128
1129    #[test]
1130    fn a_one_shot_metadata_query_does_not_read_the_snapshot_it_cannot_use() {
1131        // Revalidation stats every entry regardless of what the snapshot holds, so for
1132        // a metadata query the load and the reconciliation against it are additive cost:
1133        // measured on macOS/APFS over 494,031 entries, warm revalidation cost 4.8 s
1134        // against 3.6 s for the cold path. This holds for every view, not just the
1135        // compact summary — the tree default was the measured case.
1136        let mut tree_query = summary_query();
1137        tree_query.views = vec![ViewSpec::Tree];
1138        for policy in [CachePolicy::Auto, CachePolicy::ReadOnly] {
1139            let cached = config(policy, Some(PathBuf::from("cache.fdu")));
1140            assert!(
1141                planned(&cached, &tree_query).load != Load::Snapshot,
1142                "{policy:?} must not pay for a read that saves no work"
1143            );
1144        }
1145    }
1146
1147    #[test]
1148    fn the_snapshot_is_read_where_reading_pays_or_is_the_contract() {
1149        // `Only` answers from the snapshot; reading it is the request itself.
1150        let only = config(CachePolicy::Only, Some(PathBuf::from("cache.fdu")));
1151        assert_eq!(planned(&only, &summary_query()).load, Load::Snapshot);
1152
1153        // Analysis reuses the content sidecar, which avoids re-reading file bodies —
1154        // the one measured case where a warm read wins (639 ms to 325 ms).
1155        let analyzed = OpenFixture {
1156            analysis: crate::content::AnalysisRequest {
1157                profile: crate::content::AnalysisSet::NONE.with_code(),
1158                ..Default::default()
1159            },
1160            ..config(CachePolicy::Auto, Some(PathBuf::from("cache.fdu")))
1161        };
1162        assert_eq!(planned(&analyzed, &summary_query()).load, Load::Snapshot);
1163
1164        // `Off` and `Refresh` never read by definition.
1165        for policy in [CachePolicy::Off, CachePolicy::Refresh] {
1166            let never = config(policy, Some(PathBuf::from("cache.fdu")));
1167            assert_eq!(planned(&never, &summary_query()).load, Load::None, "{policy:?}");
1168        }
1169    }
1170
1171    #[test]
1172    fn an_analysis_request_never_selects_the_compact_summary_tier() {
1173        // Analysis reads file contents keyed by retained entries and writes its own
1174        // sidecar, so the aggregate-only tier cannot answer it even though the request
1175        // otherwise looks like the uncached unfiltered summary the planner compacts.
1176        let off = blind(CachePolicy::Off, None);
1177        assert_eq!(planned(&off, &summary_query()).retained, RetainedState::Summary);
1178
1179        for profile in [
1180            crate::content::AnalysisSet::NONE.with_lines(),
1181            crate::content::AnalysisSet::NONE.with_code(),
1182            crate::content::AnalysisSet::NONE.with_words(),
1183            crate::content::AnalysisSet::ALL,
1184        ] {
1185            let analyzed = OpenFixture {
1186                analysis: crate::content::AnalysisRequest { profile, ..Default::default() },
1187                ..blind(CachePolicy::Off, None)
1188            };
1189            assert_eq!(
1190                planned(&analyzed, &summary_query()).retained,
1191                RetainedState::FullIndex,
1192                "{profile:?} must retain the index"
1193            );
1194        }
1195    }
1196
1197    #[test]
1198    fn a_repeated_one_shot_report_scans_cold_while_open_still_revalidates() {
1199        // The same snapshot, two consumers, two right answers. A one-shot report cannot
1200        // amortise a snapshot load, so its second run scans cold again; a caller holding
1201        // the index through `open` amortises it across everything that follows, so its
1202        // second open still takes the warm path. Both report truthfully.
1203        let root = tempfile::tempdir().expect("tempdir");
1204        fs::write(root.path().join("file.txt"), b"contents").expect("file");
1205        let cache = tempfile::tempdir().expect("cache dir");
1206        let auto = config(CachePolicy::Auto, Some(cache.path().join("cache.fdu")));
1207        let mut tree_query = summary_query();
1208        tree_query.views = vec![ViewSpec::Tree];
1209
1210        let (first, pending, _) = prepared(root.path(), &auto, &tree_query).expect("first report");
1211        pending.join().expect("first save");
1212        assert_eq!(first.provenance.source, ReportSource::ColdScan);
1213        assert!(auto.cache_path.as_deref().expect("path").exists(), "first run persists");
1214
1215        let (second, pending, performance) =
1216            prepared(root.path(), &auto, &tree_query).expect("second report");
1217        pending.join().expect("second save");
1218        assert_eq!(
1219            second.provenance.source,
1220            ReportSource::ColdScan,
1221            "a repeated one-shot must not pay for a read that saves no work"
1222        );
1223        assert_eq!(performance.walked_files, 1, "the walk still happened");
1224
1225        // A default report and a default `open` both observe control state, so they share
1226        // one snapshot scope and the `open` starts from the report's snapshot.
1227        let (_, open_report) = crate::open_fixture(root.path(), &auto).expect("library open");
1228        assert_eq!(
1229            open_report.path_taken,
1230            OpenPath::WarmRevalidate,
1231            "a caller holding the index still amortises the load"
1232        );
1233    }
1234
1235    #[test]
1236    fn cache_only_still_answers_from_a_snapshot_left_by_a_one_shot_report() {
1237        // Skipping the read must not skip the write: the snapshot a default run leaves
1238        // behind is what `--cache only` answers from without touching the tree.
1239        let root = tempfile::tempdir().expect("tempdir");
1240        fs::write(root.path().join("file.txt"), b"contents").expect("file");
1241        let cache = tempfile::tempdir().expect("cache dir");
1242        let auto = config(CachePolicy::Auto, Some(cache.path().join("cache.fdu")));
1243        let mut tree_query = summary_query();
1244        tree_query.views = vec![ViewSpec::Tree];
1245
1246        let (_, pending, _) = prepared(root.path(), &auto, &tree_query).expect("report");
1247        pending.join().expect("save");
1248
1249        let only = config(CachePolicy::Only, Some(cache.path().join("cache.fdu")));
1250        let (from_cache, pending, performance, diagnostics) =
1251            prepared_with_diagnostics(root.path(), &only, &tree_query).expect("cache-only report");
1252        pending.join().expect("no save");
1253        assert_eq!(from_cache.provenance.source, ReportSource::CacheOnly);
1254        assert_eq!(performance.walked_files, 0, "cache-only never touches the tree");
1255        assert!(diagnostics.is_none(), "a cache-only open has no scan trace");
1256    }
1257
1258    #[test]
1259    fn controls_on_snapshot_projects_to_an_equivalent_controls_off_cache_only_report() {
1260        let root = tempfile::tempdir().expect("tempdir");
1261        let cache = tempfile::tempdir().expect("cache dir");
1262        let cache_path = cache.path().join("cache.fdu");
1263        fs::create_dir(root.path().join("src")).expect("source dir");
1264        fs::write(root.path().join("src/lib.rs"), b"library").expect("source file");
1265        seed_controls_snapshot(root.path(), cache_path.clone());
1266
1267        let controls_off = controls_config(CachePolicy::Only, cache_path, false);
1268        let query = Query {
1269            views: vec![
1270                ViewSpec::Summary,
1271                ViewSpec::Tree,
1272                ViewSpec::Families,
1273                ViewSpec::Types,
1274                ViewSpec::Extensions,
1275                ViewSpec::Languages,
1276                ViewSpec::Largest,
1277                ViewSpec::Recent,
1278                ViewSpec::Files,
1279            ],
1280            ..Query::default()
1281        };
1282        let (projected, pending, performance) =
1283            prepared(root.path(), &controls_off, &query).expect("projected report");
1284        pending.join().expect("no cache-only save");
1285
1286        let cold = OpenFixture { policy: CachePolicy::Off, cache_path: None, ..controls_off };
1287        let (mut expected, pending, _) =
1288            prepared(root.path(), &cold, &query).expect("controls-off cold report");
1289        pending.join().expect("no cold save");
1290        expected.provenance = projected.provenance.clone();
1291
1292        assert_eq!(performance.source, ReportSource::CacheOnly);
1293        assert_eq!(projected.scope, cold.scan.scope());
1294        assert_eq!(projected.ignore_rules, crate::control::ControlCoverage::NotObserved);
1295        assert_eq!(
1296            crate::report_format::render(&projected, crate::report_format::Format::Json, false,)
1297                .expect("compatible report format"),
1298            crate::report_format::render(&expected, crate::report_format::Format::Json, false,)
1299                .expect("compatible report format"),
1300        );
1301    }
1302
1303    #[test]
1304    fn controls_on_snapshot_projects_to_controls_off_auto_report() {
1305        let root = tempfile::tempdir().expect("tempdir");
1306        let cache = tempfile::tempdir().expect("cache dir");
1307        let cache_path = cache.path().join("cache.fdu");
1308        seed_controls_snapshot(root.path(), cache_path.clone());
1309
1310        let controls_off = OpenFixture {
1311            analysis: crate::content::AnalysisRequest {
1312                profile: crate::content::AnalysisSet::NONE.with_lines(),
1313                ..Default::default()
1314            },
1315            ..controls_config(CachePolicy::Auto, cache_path, false)
1316        };
1317        let (report, pending, performance) = prepared(root.path(), &controls_off, &summary_query())
1318            .expect("controls-off warm projection");
1319        pending.join().expect("save content only");
1320
1321        assert_eq!(report.provenance.source, ReportSource::WarmRevalidate);
1322        assert_eq!(report.scope, controls_off.scan.scope());
1323        assert_eq!(performance.source, ReportSource::WarmRevalidate);
1324    }
1325
1326    /// Control sources past both limits, so no scan can observe them without saying so.
1327    ///
1328    /// The root rule is longer than the line limit and the nested source is past the
1329    /// table budget. An observing scan refuses both and records it in its control coverage;
1330    /// a report whose scope observes no control state read neither.
1331    fn write_unobservable_controls(root: &Path) {
1332        let mut rule = vec![b'a'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
1333        rule.push(b'\n');
1334        fs::write(root.join(".gitignore"), rule).expect("oversized rule");
1335        fs::create_dir(root.join("vendored")).expect("nested directory");
1336        fs::write(
1337            root.join("vendored/.gitignore"),
1338            b"x\n".repeat(crate::control::DEFAULT_CONTROL_BUDGET / 2),
1339        )
1340        .expect("oversized source");
1341    }
1342
1343    #[test]
1344    fn a_one_shot_report_observes_control_state_as_its_caller_configures() {
1345        // A default report reads every `.gitignore`, and a file past a bound is refused and
1346        // named without ending the report or its snapshot. A report that turns observation
1347        // off reads neither file, and says so in its report and in any snapshot it writes.
1348        let root = tempfile::tempdir().expect("tempdir");
1349        fs::write(root.path().join("file.txt"), b"contents").expect("file");
1350        write_unobservable_controls(root.path());
1351        let mut tree_query = summary_query();
1352        tree_query.views = vec![ViewSpec::Tree];
1353
1354        for read_controls in [true, false] {
1355            let cache = tempfile::tempdir().expect("cache dir");
1356            let cache_path = cache.path().join("cache.fdu");
1357            let caller = controls_config(CachePolicy::Auto, cache_path.clone(), read_controls);
1358            for query in [summary_query(), tree_query.clone()] {
1359                let (report, pending, _) = prepared(root.path(), &caller, &query)
1360                    .expect("a refused control file ends nothing");
1361                pending.join().expect("save");
1362                assert!(
1363                    report.status.complete,
1364                    "a refusal is not a partial: {:?}",
1365                    report.status.errors
1366                );
1367                assert_eq!(report.scope, caller.scan.scope());
1368                match &report.ignore_rules {
1369                    crate::control::ControlCoverage::Observed(coverage) => {
1370                        assert!(read_controls, "observed only when asked");
1371                        assert_eq!(coverage.refused, 2, "{coverage:?}");
1372                        assert_eq!(report.notes.len(), 1, "{:?}", report.notes);
1373                    }
1374                    crate::control::ControlCoverage::NotObserved => {
1375                        assert!(!read_controls, "unobserved only when turned off");
1376                        assert!(report.notes.is_empty(), "{:?}", report.notes);
1377                    }
1378                }
1379            }
1380
1381            let saved = crate::snapshot::load(&cache_path)
1382                .expect("load the snapshot")
1383                .expect("the index tier persisted");
1384            assert_eq!(saved.scope(), caller.scan.scope());
1385            assert_eq!(saved.controls().is_ok(), read_controls);
1386        }
1387    }
1388
1389    /// Which failure a run names, and what kind of failure it is, must not depend on how it
1390    /// was delivered.
1391    ///
1392    /// A scope this build cannot honour is refused by every policy, including the one that
1393    /// never scans: under `--cache only` the scan that would have refused it never runs, so
1394    /// the run used to report a snapshot miss instead -- the same request naming two
1395    /// different failures depending on its delivery, which the path-independence registry
1396    /// records as `refusal-order` for `--one-filesystem` on Windows. `follow_symlinks` is
1397    /// the same rule on every platform, so this test runs where the Windows case cannot.
1398    ///
1399    /// The refusal is the request model's typed one, not an engine error the surfaces then
1400    /// classify differently: reporting it as an engine error made the command line exit 1
1401    /// where Python raised `ValueError`, one request with two kinds of outcome.
1402    #[test]
1403    fn a_scope_this_build_cannot_honour_is_refused_before_any_snapshot_is_read() {
1404        let root = tempfile::tempdir().expect("tempdir");
1405        fs::write(root.path().join("file.txt"), b"contents").expect("file");
1406        let cache = tempfile::tempdir().expect("cache dir");
1407        let cache_path = cache.path().join("cache.fdu");
1408
1409        // A usable snapshot exists, so a cache-only read of a scope this build supports
1410        // answers from it.
1411        let warm = config(CachePolicy::Auto, Some(cache_path.clone()));
1412        let (_, pending, _) = prepared(root.path(), &warm, &summary_query()).expect("warm");
1413        pending.join().expect("save");
1414        let (_, pending, _) = prepared(
1415            root.path(),
1416            &config(CachePolicy::Only, Some(cache_path.clone())),
1417            &summary_query(),
1418        )
1419        .expect("the snapshot answers a supported scope");
1420        pending.join().expect("no save");
1421
1422        let unsupported = ScanConfig { follow_symlinks: true, ..ScanConfig::default() };
1423        for policy in
1424            [CachePolicy::Only, CachePolicy::Off, CachePolicy::Auto, CachePolicy::ReadOnly]
1425        {
1426            let asked = OpenFixture {
1427                scan: unsupported.clone(),
1428                ..config(policy, Some(cache_path.clone()))
1429            };
1430            let refused = prepared(root.path(), &asked, &summary_query())
1431                .expect_err("a scope this build cannot honour has no answer at any policy");
1432            assert!(
1433                matches!(
1434                    refused,
1435                    Error::InvalidRequest(crate::query::RequestError::ScopeUnsupported {
1436                        axis: crate::query::ScopeAxis::FollowSymlinks,
1437                        ..
1438                    })
1439                ),
1440                "{policy:?} must refuse the request rather than fail the operation: {refused}"
1441            );
1442            assert_eq!(
1443                refused.to_string(),
1444                "unsupported scan configuration: follow_symlinks requires cycle, root-boundary, \
1445                 and filesystem-boundary semantics",
1446                "{policy:?} must name the scope it cannot honour"
1447            );
1448        }
1449    }
1450
1451    #[test]
1452    fn a_report_that_reads_no_gitignore_refuses_to_select_by_ignored_state() {
1453        // No entry of a scan that read no rule can be shown to be ignored or not, so the
1454        // request is refused rather than answered with every entry or none.
1455        let root = tempfile::tempdir().expect("tempdir");
1456        fs::write(root.path().join(".gitignore"), b"*.log\n").expect("control file");
1457        fs::write(root.path().join("debug.log"), b"ignored").expect("ignored file");
1458        let mut only = summary_query();
1459        only.selection.ignored = IgnoredEntries::Only;
1460
1461        // Refused by the request model before anything is scanned: the compact summary
1462        // tier this request would take reaches no reader, so a check made there would not
1463        // cover this route at all.
1464        assert!(matches!(
1465            prepared(root.path(), &blind(CachePolicy::Off, None), &only),
1466            Err(Error::InvalidRequest(crate::query::RequestError::IgnoredWithoutObservation(
1467                IgnoredEntries::Only
1468            )))
1469        ));
1470
1471        let (report, pending, _) =
1472            prepared(root.path(), &config(CachePolicy::Off, None), &only).expect("observed");
1473        pending.join().expect("no save");
1474        let Section::Summary(row) = report.sections[0] else { panic!("a summary") };
1475        assert_eq!((row.files, row.bytes), (1, 7), "only the ignored file is selected");
1476    }
1477
1478    #[test]
1479    fn a_default_reports_snapshot_serves_either_cache_only_report_but_not_the_reverse() {
1480        // Every surface reaches this planner observing control state by default, so a
1481        // snapshot any default report wrote serves the next one. A cache-only report that
1482        // turns observation off also answers from it, reading only the all-entry facts. An
1483        // opted-out snapshot holds no classification, so it cannot serve a default report,
1484        // and the refusal says why and what recovers.
1485        let root = tempfile::tempdir().expect("tempdir");
1486        fs::write(root.path().join("file.txt"), b"contents").expect("file");
1487        let mut tree_query = summary_query();
1488        tree_query.views = vec![ViewSpec::Tree];
1489
1490        for (writer, reader) in [(true, true), (true, false), (false, false), (false, true)] {
1491            let cache = tempfile::tempdir().expect("cache dir");
1492            let cache_path = cache.path().join("cache.fdu");
1493            let write = controls_config(CachePolicy::Auto, cache_path.clone(), writer);
1494            let (_, pending, _) =
1495                prepared(root.path(), &write, &tree_query).expect("writing report");
1496            pending.join().expect("save");
1497
1498            let read = controls_config(CachePolicy::Only, cache_path, reader);
1499            match prepared(root.path(), &read, &tree_query) {
1500                Ok((report, pending, _)) => {
1501                    pending.join().expect("no save");
1502                    assert!(writer || !reader, "writer {writer} served reader {reader}");
1503                    assert_eq!(report.provenance.source, ReportSource::CacheOnly);
1504                    assert_eq!(
1505                        matches!(report.ignore_rules, crate::control::ControlCoverage::Observed(_)),
1506                        reader,
1507                        "a report describes the scope it asked for"
1508                    );
1509                }
1510                Err(Error::Snapshot(message)) => {
1511                    assert!(!writer && reader, "writer {writer}, reader {reader}: {message}");
1512                    assert!(message.contains(".gitignore state"), "names the cause: {message}");
1513                    assert!(message.contains("`auto`"), "names the remedy: {message}");
1514                }
1515                Err(other) => panic!("writer {writer}, reader {reader}: {other}"),
1516            }
1517        }
1518    }
1519
1520    #[test]
1521    fn a_cache_only_open_answers_from_a_default_reports_snapshot() {
1522        // A default report and a default `open` share one scope, so the one policy that
1523        // forbids a scan answers the `open` from the report's snapshot, with the
1524        // classification the report observed.
1525        let root = tempfile::tempdir().expect("tempdir");
1526        fs::write(root.path().join(".gitignore"), b"*.log\n").expect("control file");
1527        fs::write(root.path().join("debug.log"), b"ignored").expect("ignored file");
1528        let cache = tempfile::tempdir().expect("cache dir");
1529        let cache_path = cache.path().join("cache.fdu");
1530        let mut tree_query = summary_query();
1531        tree_query.views = vec![ViewSpec::Tree];
1532
1533        let auto = config(CachePolicy::Auto, Some(cache_path.clone()));
1534        let (_, pending, _) = prepared(root.path(), &auto, &tree_query).expect("report");
1535        pending.join().expect("save");
1536
1537        let only = config(CachePolicy::Only, Some(cache_path));
1538        let (index, report) = crate::open_fixture(root.path(), &only).expect("the shared snapshot");
1539        assert_eq!(report.path_taken, OpenPath::CacheOnly);
1540        assert_eq!(index.is_ignored(Path::new("debug.log")).ok(), Some(Some(true)));
1541    }
1542
1543    #[test]
1544    fn an_open_that_opts_out_of_control_state_shares_an_opted_out_reports_snapshot() {
1545        // A report and an `open` that both turn observation off write and want one scope,
1546        // so that open answers from the report's snapshot without touching the tree, under
1547        // the one policy that forbids a scan, and says it cannot classify ignored entries.
1548        let root = tempfile::tempdir().expect("tempdir");
1549        fs::write(root.path().join("file.txt"), b"contents").expect("file");
1550        let cache = tempfile::tempdir().expect("cache dir");
1551        let cache_path = cache.path().join("cache.fdu");
1552        let mut tree_query = summary_query();
1553        tree_query.views = vec![ViewSpec::Tree];
1554
1555        let auto = blind(CachePolicy::Auto, Some(cache_path.clone()));
1556        let (_, pending, _) = prepared(root.path(), &auto, &tree_query).expect("report");
1557        pending.join().expect("save");
1558        assert!(cache_path.exists(), "the report left a snapshot");
1559
1560        let only = blind(CachePolicy::Only, Some(cache_path));
1561        let (index, report) = crate::open_fixture(root.path(), &only).expect("the shared snapshot");
1562        assert_eq!(report.path_taken, OpenPath::CacheOnly);
1563        assert!(matches!(
1564            index.is_ignored(Path::new("file.txt")),
1565            Err(Error::ControlStateNotObserved)
1566        ));
1567    }
1568
1569    #[test]
1570    fn compact_summary_matches_the_indexed_summary_exactly() {
1571        let root = tempfile::tempdir().expect("tempdir");
1572        fs::create_dir(root.path().join("src")).expect("directory");
1573        fs::write(root.path().join("src/lib.rs"), b"library").expect("file");
1574        fs::write(root.path().join("README.md"), b"read me").expect("file");
1575        #[cfg(unix)]
1576        std::os::unix::fs::symlink("README.md", root.path().join("readme-link")).expect("symlink");
1577
1578        let query = summary_query();
1579        // Two workers so the compact fold exercises StreamingEmission recycle even on
1580        // a one-vCPU runner (`threads: None` would take the serial walker there).
1581        let off = OpenFixture {
1582            scan: ScanConfig { read_controls: false, threads: Some(2), ..ScanConfig::default() },
1583            ..blind(CachePolicy::Off, None)
1584        };
1585        let (compact, pending, performance) =
1586            prepared(root.path(), &off, &query).expect("compact report");
1587        pending.join().expect("no pending compact save");
1588        assert_eq!(performance.walked_files, 2);
1589        assert_eq!(performance.walked_bytes, 14);
1590
1591        // Only a report that turns control observation off takes the compact tier, so the
1592        // index it must match exactly is opened under that scope too.
1593        let (index, _open_report) = crate::open_fixture(root.path(), &off).expect("indexed scan");
1594        let indexed = report(
1595            &index,
1596            &crate::test_support::read_of(&index, query.clone()),
1597            compact.provenance.generated_at,
1598        )
1599        .expect("report");
1600
1601        let Section::Summary(compact_row) = compact.sections[0] else {
1602            panic!("compact plan did not return a summary")
1603        };
1604        let Section::Summary(indexed_row) = indexed.sections[0] else {
1605            panic!("indexed plan did not return a summary")
1606        };
1607        assert_eq!(compact_row.files, indexed_row.files);
1608        assert_eq!(compact_row.dirs, indexed_row.dirs);
1609        assert_eq!(compact_row.bytes, indexed_row.bytes);
1610        assert_eq!(compact_row.allocated, indexed_row.allocated);
1611        assert_eq!(compact_row.newest_mtime_ns, indexed_row.newest_mtime_ns);
1612        assert_eq!(compact.root, indexed.root);
1613        assert_eq!(compact.scope, indexed.scope);
1614        assert_eq!(compact.status.complete, indexed.status.complete);
1615        assert_eq!(compact.provenance.freshness, indexed.provenance.freshness);
1616    }
1617
1618    #[test]
1619    fn compact_summary_never_creates_the_configured_snapshot() {
1620        let root = tempfile::tempdir().expect("tempdir");
1621        fs::write(root.path().join("payload"), b"payload").expect("file");
1622        let cache = root.path().join("must-not-exist.fdu");
1623
1624        let (report, pending, _) =
1625            prepared(root.path(), &blind(CachePolicy::Off, Some(cache.clone())), &summary_query())
1626                .expect("compact report");
1627        pending.join().expect("no pending compact save");
1628
1629        assert!(report.status.complete);
1630        assert!(!cache.exists());
1631    }
1632
1633    #[test]
1634    fn full_index_report_exposes_scan_diagnostics_when_requested() {
1635        let root = tempfile::tempdir().expect("tempdir");
1636        fs::create_dir(root.path().join("nested")).expect("directory");
1637        fs::write(root.path().join("nested/file.txt"), b"trace me").expect("file");
1638        let query = Query { views: vec![ViewSpec::Tree], ..Query::default() };
1639
1640        let (report, pending, performance, diagnostics) =
1641            prepared_with_diagnostics(root.path(), &config(CachePolicy::Off, None), &query)
1642                .expect("full-index report");
1643        pending.join().expect("no pending save");
1644
1645        assert!(report.status.complete);
1646        assert_eq!(performance.walked_files, 1);
1647        let diagnostics = diagnostics.expect("full-index scan diagnostics");
1648        assert_eq!(diagnostics.schema, crate::scan::SCAN_DIAGNOSTICS_SCHEMA);
1649        assert_eq!(diagnostics.worker_policy.ready_directories_at_finish, 0);
1650        assert_eq!(diagnostics.worker_policy.in_flight_directories_at_finish, 0);
1651    }
1652
1653    /// A tree of `dirs` directories under the root, each holding `files` files of
1654    /// distinct sizes, with its file count and byte total.
1655    fn wide_tree(dirs: usize, files: usize) -> (tempfile::TempDir, u64, u64) {
1656        let root = tempfile::tempdir().expect("tempdir");
1657        let mut bytes = 0;
1658        for directory in 0..dirs {
1659            let path = root.path().join(format!("d{directory:03}"));
1660            fs::create_dir(&path).expect("directory");
1661            for file in 0..files {
1662                let size = directory * files + file + 1;
1663                fs::write(path.join(format!("f{file}.txt")), vec![b'.'; size]).expect("file");
1664                bytes += size as u64;
1665            }
1666        }
1667        (root, (dirs * files) as u64, bytes)
1668    }
1669
1670    /// [`prepared`], reporting through `progress`.
1671    fn prepared_with_progress(
1672        root: &Path,
1673        config: &OpenFixture,
1674        query: &Query,
1675        progress: &Progress,
1676    ) -> Result<(Report, PendingSave, PerformanceSummary)> {
1677        let (request, delivery) = split(root, config, query);
1678        prepare_report_with_progress(&request, &delivery, progress)
1679    }
1680
1681    fn analyzing(fixture: OpenFixture) -> OpenFixture {
1682        OpenFixture {
1683            analysis: crate::content::AnalysisRequest {
1684                profile: crate::content::AnalysisSet::LINES_ONLY,
1685                workers: 0,
1686            },
1687            ..fixture
1688        }
1689    }
1690
1691    /// The invariant the plan makes testable: when a route completes, the handle's
1692    /// files, bytes, and allocated bytes equal the walked totals the route's own
1693    /// performance summary reports, and its directories equal the directories the route
1694    /// read. The allocated figure is also the answer's own total, which is what lets a
1695    /// display put it beside the answer. Every one-shot route: the cold full index, the
1696    /// transient summary fold, a cold run with content analysis, and a warm revalidation
1697    /// of the snapshot that run left.
1698    #[test]
1699    fn progress_ends_at_the_walked_totals_of_every_one_shot_route() {
1700        use crate::ProgressPhase::{Scanning, Summarizing};
1701        let (root, files, bytes) = wide_tree(6, 4);
1702        let tree = Query { views: vec![ViewSpec::Tree], ..Query::default() };
1703
1704        let progress = Progress::new();
1705        let (_, pending, performance) =
1706            prepared_with_progress(root.path(), &config(CachePolicy::Off, None), &tree, &progress)
1707                .expect("cold full-index report");
1708        pending.join().expect("no save");
1709        let snapshot = progress.snapshot();
1710        assert_eq!(performance.source, ReportSource::ColdScan);
1711        assert_eq!((performance.walked_files, performance.walked_bytes), (files, bytes));
1712        assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "cold full index");
1713        assert_eq!(snapshot.allocated, performance.walked_allocated);
1714        assert!(snapshot.allocated > 0, "files with content occupy blocks");
1715        assert_eq!(snapshot.directories, 7, "the root and its six children");
1716        assert_eq!(
1717            (snapshot.phase, snapshot.analysis),
1718            (Summarizing, None),
1719            "the walk ended, the index was assembled, then the answer was built"
1720        );
1721
1722        let progress = Progress::new();
1723        let (report, pending, performance) = prepared_with_progress(
1724            root.path(),
1725            &blind(CachePolicy::Off, None),
1726            &summary_query(),
1727            &progress,
1728        )
1729        .expect("compact summary report");
1730        pending.join().expect("no save");
1731        let Section::Summary(row) = report.sections[0] else { panic!("summary section") };
1732        let snapshot = progress.snapshot();
1733        assert_eq!((performance.walked_files, performance.walked_bytes), (files, bytes));
1734        assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "summary fold");
1735        assert_eq!(snapshot.allocated, performance.walked_allocated);
1736        assert_eq!(snapshot.allocated, row.allocated, "the progress figure is the answer's");
1737        assert_eq!(snapshot.directories, row.dirs + 1, "the row's directories and the root");
1738        assert_eq!((snapshot.phase, snapshot.analysis), (Scanning, None));
1739
1740        // Outside the tree: a cache inside it is two more files for the warm walk.
1741        let cache_dir = tempfile::tempdir().expect("cache dir");
1742        let cache = cache_dir.path().join("snapshot.fdu");
1743        let progress = Progress::new();
1744        let (_, pending, performance) = prepared_with_progress(
1745            root.path(),
1746            &analyzing(config(CachePolicy::Auto, Some(cache.clone()))),
1747            &tree,
1748            &progress,
1749        )
1750        .expect("cold analyzed report");
1751        let snapshot = progress.snapshot();
1752        assert_eq!(snapshot.phase, Summarizing, "the answer is built while the save runs");
1753        pending.join().expect("save");
1754        assert_eq!(performance.source, ReportSource::ColdScan);
1755        assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "cold with analysis");
1756        assert_eq!(snapshot.allocated, performance.walked_allocated);
1757        assert_eq!(snapshot.directories, 7);
1758        assert_eq!(performance.fresh_files, files, "every file is a lines candidate");
1759        assert_eq!(snapshot.analysis, Some((files, files)));
1760
1761        let progress = Progress::new();
1762        let (_, pending, performance) = prepared_with_progress(
1763            root.path(),
1764            &analyzing(config(CachePolicy::Auto, Some(cache))),
1765            &tree,
1766            &progress,
1767        )
1768        .expect("warm analyzed report");
1769        pending.join().expect("nothing to save");
1770        let snapshot = progress.snapshot();
1771        assert_eq!(performance.source, ReportSource::WarmRevalidate);
1772        assert_eq!((performance.walked_files, performance.walked_bytes), (files, bytes));
1773        assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "warm revalidation");
1774        assert_eq!(snapshot.allocated, performance.walked_allocated);
1775        assert_eq!(snapshot.directories, 7);
1776        assert_eq!(performance.fresh_files, 0, "the sidecar answered every candidate");
1777        assert_eq!((snapshot.phase, snapshot.analysis), (Summarizing, Some((0, 0))));
1778    }
1779
1780    /// A cache-only report walks nothing: it ends building the answer, and its walk
1781    /// counters stay at zero.
1782    #[test]
1783    fn a_cache_only_report_ends_summarizing_and_walks_nothing() {
1784        use crate::ProgressPhase::Summarizing;
1785        let (root, _, _) = wide_tree(3, 2);
1786        let tree = Query { views: vec![ViewSpec::Tree], ..Query::default() };
1787        let cache_dir = tempfile::tempdir().expect("cache dir");
1788        let cache = cache_dir.path().join("snapshot.fdu");
1789        let (_, pending, _) =
1790            prepared(root.path(), &config(CachePolicy::Auto, Some(cache.clone())), &tree)
1791                .expect("a report that writes the snapshot");
1792        pending.join().expect("save");
1793
1794        let progress = Progress::new();
1795        let (_, pending, performance) = prepared_with_progress(
1796            root.path(),
1797            &config(CachePolicy::Only, Some(cache)),
1798            &tree,
1799            &progress,
1800        )
1801        .expect("cache-only report");
1802        pending.join().expect("nothing to save");
1803        let snapshot = progress.snapshot();
1804        assert_eq!(performance.source, ReportSource::CacheOnly);
1805        assert_eq!(snapshot.phase, Summarizing);
1806        assert_eq!((snapshot.directories, snapshot.files, snapshot.bytes), (0, 0, 0));
1807    }
1808
1809    /// The position of `phase` in `order`, so a poller can assert phases never go back.
1810    fn rank(phase: crate::ProgressPhase, order: &[crate::ProgressPhase]) -> usize {
1811        order
1812            .iter()
1813            .position(|expected| *expected == phase)
1814            .unwrap_or_else(|| panic!("{phase:?} is not a phase of this route"))
1815    }
1816
1817    /// What a ticker thread sees: every counter non-decreasing from one snapshot to the
1818    /// next, and the phase moving only forward through the route's order. Deterministic
1819    /// without a timing assumption, because each claim is about consecutive reads of one
1820    /// monotonic cell, whatever the interleaving; the poller just reads until the run is
1821    /// over. The tree spans many worker chunks and many small batches, so the counters
1822    /// are added to from several threads while the poller reads.
1823    #[test]
1824    fn progress_is_monotonic_and_phases_advance_in_order_while_a_report_runs() {
1825        use crate::ProgressPhase::{
1826            Analyzing, Indexing, Loading, Revalidating, Saving, Scanning, Starting, Summarizing,
1827        };
1828        let (root, files, bytes) = wide_tree(48, 6);
1829        let cache_dir = tempfile::tempdir().expect("cache dir");
1830        let cache = cache_dir.path().join("snapshot.fdu");
1831        let fixture = OpenFixture {
1832            scan: ScanConfig { threads: Some(3), batch_size: 4, ..ScanConfig::default() },
1833            ..analyzing(config(CachePolicy::Auto, Some(cache)))
1834        };
1835        let tree = Query { views: vec![ViewSpec::Tree], ..Query::default() };
1836
1837        let routes: [(&str, &[crate::ProgressPhase]); 2] = [
1838            ("cold", &[Starting, Loading, Scanning, Indexing, Analyzing, Saving, Summarizing]),
1839            ("warm", &[Starting, Loading, Revalidating, Analyzing, Saving, Summarizing]),
1840        ];
1841        for (route, order) in routes {
1842            let progress = Progress::new();
1843            // Read before the run can begin, so the first phase seen is the handle's
1844            // initial one whatever the scheduler does with the poller.
1845            let initial = progress.snapshot();
1846            let done = std::sync::atomic::AtomicBool::new(false);
1847            let (performance, seen) = std::thread::scope(|scope| {
1848                let poller = scope.spawn(|| {
1849                    let polled = progress.clone();
1850                    let mut previous = initial;
1851                    let mut seen = vec![previous.phase];
1852                    loop {
1853                        let finished = done.load(std::sync::atomic::Ordering::Acquire);
1854                        let current = polled.snapshot();
1855                        assert!(current.directories >= previous.directories, "{route}");
1856                        assert!(current.files >= previous.files, "{route}");
1857                        assert!(current.bytes >= previous.bytes, "{route}");
1858                        assert!(
1859                            rank(current.phase, order) >= rank(previous.phase, order),
1860                            "{route}: {:?} after {:?}",
1861                            current.phase,
1862                            previous.phase
1863                        );
1864                        if let (Some(before), Some(after)) = (previous.analysis, current.analysis) {
1865                            assert!(after.0 >= before.0 && after.0 <= after.1, "{route}");
1866                            assert_eq!(after.1, before.1, "{route}: the total is fixed");
1867                        }
1868                        if current.phase != previous.phase {
1869                            seen.push(current.phase);
1870                        }
1871                        previous = current;
1872                        // Read once more after the run reports done, so the final state
1873                        // is checked against the last mid-run read.
1874                        if finished {
1875                            break;
1876                        }
1877                        std::thread::yield_now();
1878                    }
1879                    seen
1880                });
1881                let (_, pending, performance) =
1882                    prepared_with_progress(root.path(), &fixture, &tree, &progress)
1883                        .expect("report");
1884                pending.join().expect("save");
1885                done.store(true, std::sync::atomic::Ordering::Release);
1886                (performance, poller.join().expect("poller"))
1887            });
1888            let snapshot = progress.snapshot();
1889            assert_eq!(
1890                (snapshot.files, snapshot.bytes),
1891                (performance.walked_files, performance.walked_bytes),
1892                "{route}"
1893            );
1894            assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "{route}");
1895            assert_eq!(snapshot.directories, 49, "{route}");
1896            assert_eq!(seen.first(), Some(&Starting), "{route}: {seen:?}");
1897            assert_eq!(
1898                seen.last().copied(),
1899                Some(snapshot.phase),
1900                "{route}: the poller saw the final phase"
1901            );
1902            assert_eq!(snapshot.phase, Summarizing, "{route}: the answer is built last");
1903            if route == "cold" {
1904                assert_eq!(snapshot.analysis, Some((files, files)));
1905            } else {
1906                assert_eq!(snapshot.analysis, Some((0, 0)));
1907                assert!(!seen.contains(&Indexing), "{route}: a warm run assembles no index");
1908            }
1909        }
1910    }
1911
1912    /// The handle observes the run and changes nothing about it: the report prepared
1913    /// with one renders to the bytes of the report prepared without, and the
1914    /// performance summary is the same value, on the indexed and the compact routes.
1915    #[test]
1916    fn a_report_prepared_with_a_handle_is_the_report_prepared_without() {
1917        let (root, _, _) = wide_tree(5, 3);
1918        let tree = Query { views: vec![ViewSpec::Tree, ViewSpec::Files], ..Query::default() };
1919        let cases = [
1920            ("full index", config(CachePolicy::Off, None), tree),
1921            ("compact summary", blind(CachePolicy::Off, None), summary_query()),
1922        ];
1923        for (route, fixture, query) in cases {
1924            let (mut plain, pending, plain_performance) =
1925                prepared(root.path(), &fixture, &query).expect("plain report");
1926            pending.join().expect("no save");
1927            let progress = Progress::new();
1928            let (observed, pending, observed_performance) =
1929                prepared_with_progress(root.path(), &fixture, &query, &progress)
1930                    .expect("observed report");
1931            pending.join().expect("no save");
1932
1933            assert_eq!(plain_performance, observed_performance, "{route}");
1934            plain.provenance = observed.provenance.clone();
1935            let json = |report: &Report| {
1936                crate::report_format::render(report, crate::report_format::Format::Json, false)
1937                    .expect("render")
1938            };
1939            assert_eq!(json(&plain), json(&observed), "{route}");
1940            assert!(progress.snapshot().files > 0, "{route}: the handle did observe the run");
1941        }
1942    }
1943}