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