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 needs
7//! only its aggregate values and, when it observes `.gitignore`, the ignored share of
8//! them, so that one plan reduces the scan's observations directly and never builds an
9//! index.
10
11use std::time::SystemTime;
12
13use crate::query::{
14    Delivery, Report, ReportProvenance, ReportSource, Request, SummaryRow, TreeStatus, ViewSpec,
15    report, report_summary,
16};
17use crate::{CachePolicy, EntryKind, Error, OpenPath, PendingSave, Progress, Result, execute};
18
19/// The minimum state a one-shot report plan retains while scanning.
20///
21/// This is deliberately a small closed set.  Add another tier only when a measured view
22/// can be answered exactly from materially less state than an index; callers should not
23/// have to select it themselves.
24#[derive(Clone, Copy, PartialEq, Eq, Debug)]
25pub(crate) enum RetainedState {
26    /// One aggregate row; no path or hierarchy records survive the scan.
27    ///
28    /// A scan that observes control state keeps the control table and the few ignored
29    /// directories that head an ignored subtree, which is all it needs to classify each
30    /// entry as the index would ([`SummaryFold`]).
31    Summary,
32    /// The complete reusable metadata index.
33    FullIndex,
34}
35
36/// The engine lifecycle that will deliver an answer.
37#[derive(Clone, Copy, PartialEq, Eq, Debug)]
38pub enum Route {
39    /// A single report may retain only an aggregate.
40    OneShot,
41    /// A reusable index returned to the caller.
42    Retained,
43    /// Verification of an index already held by the caller.
44    Refresh,
45    /// A continuing observation session.
46    Watch,
47    /// Progressive discovery and serving of an opened root.
48    Opened,
49}
50
51/// Which persisted state execution may read.
52#[derive(Clone, Copy, PartialEq, Eq, Debug)]
53pub enum Load {
54    /// Start without a metadata snapshot.
55    None,
56    /// Attempt to restore a serving metadata snapshot.
57    Snapshot,
58}
59
60/// Whether execution must verify the filesystem.
61#[derive(Clone, Copy, PartialEq, Eq, Debug)]
62pub enum Verify {
63    /// Answer only from persisted facts, marked unverified.
64    None,
65    /// Observe the requested filesystem scope.
66    Filesystem,
67}
68
69/// Whether an answer fulfills the caller's delivery contract.
70#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub enum OutcomeClass {
72    /// A complete answer, or a partial answer the caller explicitly accepts.
73    Success,
74    /// An incomplete answer the caller did not accept.
75    Partial,
76}
77
78/// Validated policy shared by all engine execution routes.
79#[derive(Clone, Debug)]
80pub struct Plan {
81    pub(crate) basis: crate::query::Basis,
82    pub(crate) route: Route,
83    pub(crate) retained: RetainedState,
84    pub(crate) load: Load,
85    pub(crate) verify: Verify,
86    pub(crate) persist: bool,
87    pub(crate) delivery: Delivery,
88}
89
90impl Plan {
91    /// Classify the answer using the caller's partial-answer policy.
92    pub fn outcome(&self, status: &TreeStatus) -> OutcomeClass {
93        if status.complete || self.delivery.accept_partial {
94            OutcomeClass::Success
95        } else {
96            OutcomeClass::Partial
97        }
98    }
99    /// The semantic basis validated when the plan was constructed.
100    pub fn basis(&self) -> &crate::query::Basis {
101        &self.basis
102    }
103    /// The lifecycle this plan executes.
104    pub const fn route(&self) -> Route {
105        self.route
106    }
107    /// The persisted state this plan may read.
108    pub const fn load(&self) -> Load {
109        self.load
110    }
111    /// The verification this plan performs.
112    pub const fn verify(&self) -> Verify {
113        self.verify
114    }
115    /// Whether this plan may write the snapshot and its content sidecar.
116    ///
117    /// Each tier still writes only under its own rule (`Plan::writes`); this is the
118    /// authorization those rules start from, and a caller deciding whether to warn that a
119    /// run leaves nothing behind asks it here rather than of the policy, whose meaning
120    /// under `Auto` depends on the route and the analysis.
121    pub const fn persists(&self) -> bool {
122        self.persist
123    }
124    /// The caller's operational choices after validation.
125    pub fn delivery(&self) -> &Delivery {
126        &self.delivery
127    }
128}
129
130/// Stored tier declarations and restoration evidence presented to a plan.
131pub(crate) struct StoreHeader<'a> {
132    pub(crate) root: &'a std::path::Path,
133    pub(crate) snapshot: crate::SnapshotIdentity,
134    pub(crate) content: Option<&'a crate::ContentTierIdentity>,
135    pub(crate) content_complete: bool,
136}
137
138/// Why persisted state cannot deliver a planned answer.
139#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140pub(crate) enum Admission {
141    Serve(crate::Serves),
142    NoLocation,
143    Missing,
144    WrongRoot,
145    WrongScope,
146    IncompleteContent,
147}
148
149impl Plan {
150    /// The one decision of whether stored state answers this plan's basis.
151    ///
152    /// Every route that reads a snapshot admits it here, warm and cache-only alike, and
153    /// persistence asks the same question of the header on disk before it decides what an
154    /// unchanged pass owes the store. The content arm applies only to a plan that verifies
155    /// nothing, because a verifying route re-reads what its sidecar lacks.
156    pub(crate) fn admit(
157        &self,
158        stored: Option<&StoreHeader<'_>>,
159        basis: &crate::query::Basis,
160    ) -> Admission {
161        if self.delivery.cache_path.is_none() {
162            return Admission::NoLocation;
163        }
164        let Some(stored) = stored else {
165            return Admission::Missing;
166        };
167        if stored.root != basis.root {
168            return Admission::WrongRoot;
169        }
170        let relation = crate::serves_snapshot(stored.snapshot, basis.scope.snapshot_identity());
171        if relation == crate::Serves::Refuse {
172            return Admission::WrongScope;
173        }
174        if self.verify == Verify::None && basis.content.is_enabled() {
175            let wanted = crate::ContentTierIdentity::for_request(
176                basis.scope.snapshot_identity().entries,
177                basis.content,
178            );
179            if !stored.content_complete
180                || stored.content.and_then(|identity| wanted.admit(identity)).is_none()
181            {
182                return Admission::IncompleteContent;
183            }
184        }
185        Admission::Serve(relation)
186    }
187}
188
189/// Facts observed by execution, independent of the route that observed them.
190#[derive(Clone, Copy, Debug)]
191#[allow(clippy::struct_excessive_bools)]
192pub(crate) struct RunFacts {
193    pub(crate) entries_verified: bool,
194    pub(crate) entries_changed: bool,
195    pub(crate) content_changed: bool,
196    pub(crate) content_requested: bool,
197    pub(crate) projected: bool,
198    pub(crate) paired_entries: bool,
199}
200
201/// Artifacts the plan authorizes its executor to write.
202#[derive(Clone, Copy, Debug, PartialEq, Eq)]
203pub(crate) struct SaveTargets {
204    pub(crate) metadata: bool,
205    pub(crate) content: bool,
206}
207
208impl SaveTargets {
209    pub(crate) const fn none(self) -> bool {
210        !self.metadata && !self.content
211    }
212}
213
214impl Plan {
215    pub(crate) fn writes(&self, run: RunFacts) -> SaveTargets {
216        let allowed = self.persist && self.delivery.cache_path.is_some();
217        SaveTargets {
218            metadata: allowed && run.entries_verified && run.entries_changed && !run.projected,
219            content: allowed
220                && run.content_requested
221                && run.content_changed
222                && (run.entries_verified || run.paired_entries),
223        }
224    }
225}
226
227/// Operational work behind one one-shot report.
228///
229/// This is deliberately separate from [`Report`]: it is transient CLI telemetry, not
230/// part of the stable machine-report schema or the pure query result.
231#[derive(Clone, Copy, PartialEq, Eq, Debug)]
232pub struct PerformanceSummary {
233    /// Regular files whose metadata was observed during this run.
234    pub walked_files: u64,
235    /// Apparent bytes represented by those walked files.
236    pub walked_bytes: u64,
237    /// Allocated bytes of those walked files, the figure to show beside an answer
238    /// measured in allocated bytes.
239    pub walked_allocated: u64,
240    /// Fresh content-analysis candidates processed.
241    pub fresh_files: u64,
242    /// Bytes actually returned by fresh content reads.
243    pub bytes_read: u64,
244    /// Wall time spent processing fresh analysis candidates.
245    pub analysis_ns: u64,
246    /// Content-analysis records restored from the sidecar.
247    pub cached_files: u64,
248    /// Apparent bytes represented by restored content records.
249    pub cached_bytes: u64,
250    /// Metadata cache tier used for this report.
251    pub source: ReportSource,
252}
253
254impl Default for PerformanceSummary {
255    fn default() -> Self {
256        Self {
257            walked_files: 0,
258            walked_bytes: 0,
259            walked_allocated: 0,
260            fresh_files: 0,
261            bytes_read: 0,
262            analysis_ns: 0,
263            cached_files: 0,
264            cached_bytes: 0,
265            source: ReportSource::ColdScan,
266        }
267    }
268}
269
270impl PerformanceSummary {
271    /// Total metadata throughput over the same elapsed sample as the report duration.
272    /// GiB/s represents walked size, not storage read bandwidth.
273    pub fn total_throughput(
274        self,
275        elapsed: std::time::Duration,
276        size: crate::query::SizeMetric,
277    ) -> String {
278        let bytes = match size {
279            crate::query::SizeMetric::Apparent => self.walked_bytes,
280            crate::query::SizeMetric::Allocated => self.walked_allocated,
281        };
282        throughput_rates(self.walked_files, bytes, elapsed).map_or_else(
283            || "throughput unavailable".to_owned(),
284            |(files, gib)| format!("{files} files/s ({gib} GiB/s)"),
285        )
286    }
287
288    fn from_open_report(report: &crate::OpenReport) -> Self {
289        let analysis = report.analysis.unwrap_or_default();
290        Self {
291            walked_files: report.scan.files_walked,
292            walked_bytes: report.scan.bytes_walked,
293            walked_allocated: report.scan.allocated_walked,
294            fresh_files: analysis.candidates,
295            bytes_read: analysis.bytes_read,
296            analysis_ns: analysis.elapsed_ns,
297            cached_files: report.content_cache.hits,
298            cached_bytes: report.content_cache.bytes,
299            source: match report.path_taken {
300                OpenPath::ColdScan => ReportSource::ColdScan,
301                OpenPath::WarmRevalidate => ReportSource::WarmRevalidate,
302                OpenPath::CacheOnly => ReportSource::CacheOnly,
303            },
304        }
305    }
306}
307
308/// Cumulative walk rates over one actual elapsed sample. The byte rate is binary GiB/s,
309/// rounded to three decimals; the grouped file rate counts complete files per second.
310/// Returns `(files_per_second, gib_per_second)` without unit labels, or `None` when
311/// elapsed time is zero. Neither rate estimates storage read bandwidth.
312pub fn throughput_rates(
313    files: u64,
314    bytes: u64,
315    elapsed: std::time::Duration,
316) -> Option<(String, String)> {
317    let ns = elapsed.as_nanos();
318    if ns == 0 {
319        return None;
320    }
321    let files_per_second = u128::from(files) * 1_000_000_000 / ns;
322    let gib_denominator = ns * (1_u128 << 30);
323    let gib_thousandths =
324        (u128::from(bytes) * 1_000_000_000 * 1_000 + gib_denominator / 2) / gib_denominator;
325    Some((
326        crate::report_format::human_count_u128(files_per_second),
327        format!("{}.{:03}", gib_thousandths / 1_000, gib_thousandths % 1_000),
328    ))
329}
330
331/// Validate a request and derive the least-retention plan for its delivery and route.
332///
333/// A summary reducer is legal when no content analysis is requested, the sole requested
334/// view is an unfiltered summary, the scan retains its whole population, and the policy
335/// does not require the snapshot to participate.  [`crate::open`] and live sessions still
336/// promise an index and therefore always plan full retention. Any future requirement the
337/// compact tier cannot prove falls closed to `RetainedState::FullIndex`.
338///
339/// Control observation is the caller's decision, not this planner's: a report's rows carry
340/// the ignored share of every size they show (fdu-elnn), so a scan that reads `.gitignore`
341/// displays what it paid for, and one that turned it off shows no share rather than a zero.
342/// The summary reducer classifies each entry against the control table the index would
343/// hold and folds the ignored share without retaining the entry (fdu-1ovb), so the default
344/// `fdu --view summary` takes it as well. A narrowed population (`--ignored=exclude|only`)
345/// is also a selection by ignored state, which `is_unfiltered` sends to the index; the
346/// planner checks the scope's population as well rather than rest on validation pairing
347/// the two.
348///
349/// The compact tier is not gated on the cache being unavailable, because for an
350/// unfiltered metadata summary the snapshot cannot save the work the scan is doing.
351/// Revalidating a loaded snapshot stats every entry anyway, so the reusable index and its
352/// write are additive cost with nothing to amortise them: measured on Linux/ext4 over
353/// 84,539 entries, the compact tier answered in 71 ms against 161 ms for a warm
354/// revalidating `Auto` run, and even a no-scan stale answer cost 81 ms because
355/// deserialisation is about as expensive per record as a warm walk.  A snapshot earns its
356/// keep when it avoids expensive work — re-reading file bodies for content analysis, or a
357/// cold filesystem walk — not when it merely mirrors a walk that still has to happen.
358///
359/// Two deliveries still require the index, for reasons that are about intent rather than
360/// cost.  [`Delivery::stale_ok`] must answer from the snapshot without touching the tree,
361/// so it has no scan to reduce.  [`CachePolicy::On`] is an explicit request to leave a
362/// current snapshot, and honouring it means materialising the index that gets written —
363/// though with no cache path configured there is nothing to write, and the compact tier
364/// answers it like any other summary.
365///
366/// Persistence follows the same reasoning as the read.  Under [`CachePolicy::Auto`] a
367/// one-shot metadata report writes nothing, because no later one-shot report reads what
368/// it would store: on a million-entry Linux tree the write was 0.26 s of a 1.51 s default
369/// run.  Content analysis writes, because its sidecar spares re-reading unchanged files
370/// and is paired with the snapshot beside it; sessions, watches, and refreshes write,
371/// because they are the later reader.
372pub fn plan(
373    request: &Request,
374    delivery: &Delivery,
375    route: Route,
376) -> std::result::Result<Plan, crate::query::RequestError> {
377    request.validate()?;
378    let mut normalized = delivery.clone();
379    if route == Route::Watch {
380        normalized.watch.get_or_insert_with(crate::query::WatchDelivery::default);
381    }
382    let delivery = &normalized;
383    request.validate_delivery(delivery)?;
384    if route == Route::Opened {
385        if delivery.cache != CachePolicy::Off
386            || delivery.watch.is_some()
387            || request.basis.content.is_enabled()
388        {
389            return Err(crate::query::RequestError::DeliveryUnsupported {
390                route: "opened",
391                reason: "progressive discovery requires cache off, no content analyzers, and observation configured through OpenOptions",
392            });
393        }
394        // An opened root runs one breadth-first producer and publishes coverage as state
395        // rather than as one answer, so these fields have no effect there. Refused rather
396        // than dropped: a delivery the route accepts is one it executes.
397        if delivery.workers.scan.is_some()
398            || delivery.order != crate::ScanOrder::default()
399            || delivery.accept_partial
400        {
401            return Err(crate::query::RequestError::DeliveryUnsupported {
402                route: "opened",
403                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",
404            });
405        }
406    }
407    if route == Route::Refresh && delivery.stale_ok {
408        return Err(crate::query::RequestError::DeliveryUnsupported {
409            route: "refresh",
410            reason: "a stale answer cannot verify filesystem state",
411        });
412    }
413    let analysis_requested = request.basis.content.is_enabled();
414    let summary_is_sufficient = request.query.views.as_slice() == [ViewSpec::Summary]
415        && request.query.selection.is_unfiltered()
416        && request.basis.scope.population == crate::query::IgnoredEntries::Include;
417    let policy_requires_index =
418        delivery.stale_ok || (delivery.cache == CachePolicy::On && delivery.cache_path.is_some());
419    // What a later request can reuse decides both directions. A one-shot metadata query
420    // cannot amortize loading and reconciling a snapshot: both paths stat every entry. On
421    // macOS/APFS (494,031 entries), warm revalidation cost 4.8 s versus 3.6 s cold. Nor
422    // does a later one-shot metadata query read what it would write. Content avoids body
423    // reads, and retained routes are their own later reader.
424    let stored_state_pays = route != Route::OneShot || analysis_requested;
425    let read_snapshot = delivery.stale_ok
426        || match delivery.cache {
427            CachePolicy::Off => false,
428            CachePolicy::Auto | CachePolicy::On => stored_state_pays,
429        };
430    let persist = !delivery.stale_ok
431        && match delivery.cache {
432            CachePolicy::Off => false,
433            CachePolicy::Auto => stored_state_pays,
434            CachePolicy::On => true,
435        };
436    Ok(Plan {
437        basis: request.basis.clone(),
438        route,
439        retained: if route == Route::OneShot
440            && !policy_requires_index
441            && !analysis_requested
442            && summary_is_sufficient
443        {
444            RetainedState::Summary
445        } else {
446            RetainedState::FullIndex
447        },
448        load: if read_snapshot { Load::Snapshot } else { Load::None },
449        verify: if delivery.stale_ok { Verify::None } else { Verify::Filesystem },
450        persist,
451        delivery: delivery.clone(),
452    })
453}
454
455/// Execute a one-shot report, retaining the least state the request needs.
456///
457/// The returned report is complete as a value even while the optional save runs.  The
458/// caller must join the handle before exit; dropping it also joins defensively.
459///
460/// This is the contract the command line has always run under, and until now the only way
461/// to get it was to be the command line. `open` takes the session path: it retains an
462/// index and writes a snapshot, which is right for a caller asking many questions and
463/// wrong for one asking a single question -- an unfiltered summary is answered by a
464/// transient tier that retains no index, and writing a snapshot for it caches state the
465/// walk did not save. A Python caller therefore left
466/// cache state on a tree that the same command would not have, which a later cache-only
467/// read could see (fdu-4msv).
468///
469/// The report observes `.gitignore` control state as the request's
470/// [`ScanConfig::read_controls`](crate::ScanConfig) says, on by default as for
471/// [`crate::open`], so the two share one snapshot scope. Observing,
472/// every tree, summary, extension, and file row carries its ignored share, and
473/// [`Report::ignore_rules`](crate::query::Report::ignore_rules) names any file a control
474/// limit refused, by the budget or by the line limit. Turned off, no `.gitignore` is
475/// read, every share is `None`, and a selection by ignored state is refused with
476/// [`Error::InvalidRequest`] before anything is scanned. Such a report reads a default
477/// snapshot under every reading policy by constructing a requested-scope index from its
478/// all-entry facts and discarding its classification.
479///
480/// The caller owns the returned [`PendingSave`] and decides when to join it, exactly as
481/// the command line does, so a renderer can run while the snapshot is still being written.
482pub fn prepare_report(
483    request: &Request,
484    delivery: &Delivery,
485) -> Result<(Report, PendingSave, PerformanceSummary)> {
486    prepare_report_internal(request, delivery, false, None)
487        .map(|(report, pending, performance, _diagnostics)| (report, pending, performance))
488}
489
490/// Execute a one-shot report, reporting its progress through `progress` as it runs.
491///
492/// The same contract and the same answer as [`prepare_report`]: the handle observes the
493/// run and changes nothing about it, so a report prepared with one is byte-for-byte the
494/// report prepared without. The caller polls [`Progress::snapshot`] from another thread
495/// while this blocks, typically to draw a wait indicator. Which phases the run passes
496/// through, what the counters mean, and what holds when this returns are documented on
497/// [`Progress`]; in short, the walk counters equal the returned
498/// [`PerformanceSummary`]'s walked totals, and a run that requested content analysis
499/// leaves `analysis` at `(fresh_files, fresh_files)`.
500///
501/// A run over a full index ends in [`ProgressPhase::Summarizing`](crate::ProgressPhase)
502/// while it builds the answer; a save it started continues in the background, and the
503/// caller decides when to join it, as with [`prepare_report`]. `Saving` is therefore
504/// shown only for the moment between the save's start and the answer's, however long
505/// the write takes.
506pub fn prepare_report_with_progress(
507    request: &Request,
508    delivery: &Delivery,
509    progress: &Progress,
510) -> Result<(Report, PendingSave, PerformanceSummary)> {
511    prepare_report_internal(request, delivery, false, Some(progress))
512        .map(|(report, pending, performance, _diagnostics)| (report, pending, performance))
513}
514
515/// Execute a one-shot report and retain scan diagnostics.
516///
517/// Public because the command line needs it and the command line is an ordinary consumer:
518/// it drives repository-controlled measurement of the installed binary. Kept separate
519/// from [`prepare_report`] so callers who do not want traces pay for neither collection
520/// nor serialization. The diagnostic value is present only when the report performs a
521/// cold scan; cache-only opens do not scan, and warm reconciliation has a different
522/// execution contract.
523pub fn prepare_report_with_scan_diagnostics(
524    request: &Request,
525    delivery: &Delivery,
526) -> Result<(Report, PendingSave, PerformanceSummary, Option<crate::scan::ScanDiagnostics>)> {
527    prepare_report_internal(request, delivery, true, None)
528}
529
530fn prepare_report_internal(
531    request: &Request,
532    delivery: &Delivery,
533    collect_scan_diagnostics: bool,
534    progress: Option<&Progress>,
535) -> Result<(Report, PendingSave, PerformanceSummary, Option<crate::scan::ScanDiagnostics>)> {
536    // Before anything is scanned, loaded, or reduced: a request its own basis cannot answer
537    // has no answer at any cost, and the compact summary tier below never reaches a reader,
538    // so a check made there would not cover this route at all. A scope this build cannot
539    // honour is part of that one check rather than a second one beside it, which is what
540    // keeps the refusal independent of the delivery: the cache-only tier never scans and
541    // the cold tier never loads, so a rule stated at either would hold for one of them.
542    request.validate().map_err(Error::InvalidRequest)?;
543    let scan_config = crate::ScanConfig {
544        progress: progress.cloned(),
545        ..request.basis.scope.scan_config(delivery)
546    };
547    let root = request.basis.root.as_path();
548    let scan_started_at = SystemTime::now();
549    let plan = plan(request, delivery, Route::OneShot).map_err(Error::InvalidRequest)?;
550    match plan.retained {
551        RetainedState::Summary => {
552            let root = root.canonicalize().map_err(|error| Error::io(root, error))?;
553            let mut fold = SummaryFold::new(&scan_config);
554            let mut reduce = |observed: &crate::ObservationOp| fold.observe(observed);
555            let (mut scan, scan_diagnostics) = if collect_scan_diagnostics {
556                let (scan, diagnostics) = crate::scan::scan_summary_fold_with_diagnostics(
557                    &root,
558                    &scan_config,
559                    &mut reduce,
560                )?;
561                (scan, Some(diagnostics))
562            } else {
563                (crate::scan::scan_summary_fold(&root, &scan_config, &mut reduce)?, None)
564            };
565            let complete = scan.is_complete();
566            let generated_at = SystemTime::now();
567            let (summary, ignore_rules, ignored_unverified) = fold.finish(&root, &scan.errors)?;
568            let report = report_summary(
569                &root,
570                scan_config.scope(),
571                request,
572                summary,
573                ignore_rules,
574                ignored_unverified,
575                TreeStatus::of_walk(&root, &mut scan),
576                ReportProvenance::of_walk(scan_started_at, generated_at, complete),
577            );
578            let performance = PerformanceSummary {
579                walked_files: scan.files_walked,
580                walked_bytes: scan.bytes_walked,
581                walked_allocated: scan.allocated_walked,
582                source: ReportSource::ColdScan,
583                ..PerformanceSummary::default()
584            };
585            Ok((report, PendingSave::none(), performance, scan_diagnostics))
586        }
587        RetainedState::FullIndex => {
588            let (index, open_report, pending_save, scan_diagnostics) =
589                execute(&plan, &request.basis, collect_scan_diagnostics, progress)?;
590            let performance = PerformanceSummary::from_open_report(&open_report);
591            if let Some(progress) = progress {
592                progress.enter(crate::ProgressPhase::Summarizing);
593            }
594            let answer = report(&index, request, SystemTime::now())?;
595            debug_assert_eq!(answer.scope, scan_config.scope());
596            // The answer is complete and owns no part of the index, so freeing it is no
597            // longer the caller's wait.
598            crate::release_index(index);
599            Ok((answer, pending_save, performance, scan_diagnostics))
600        }
601    }
602}
603
604/// The transient summary tier's reducer: the root roll-up the index would report, folded
605/// from the walk's observations as they arrive, with nothing retained per entry.
606///
607/// Observing `.gitignore`, it also classifies every entry as the detached index builder
608/// does, and tallies the entries no rule ignores beside every entry, the index's
609/// `unignored` and `all` partitions; the share it reports is
610/// [`IgnoredTally::between`](crate::query::IgnoredTally) the two, the index's own formula.
611///
612/// **Why each classification is the index's.** The builder classifies a child from two
613/// inputs: its parent's classification, since nothing below an ignored directory can be
614/// re-included, and the admitted control files of the directories above it, deepest
615/// first. The fold has both when each entry arrives:
616///
617/// - A worker publishes a listing before any directory in it becomes claimable, so an
618///   entry arrives after its parent's own observation, as a listing reaches the builder
619///   after its parent's.
620/// - A classifying walk sends each directory's control ahead of its entries
621///   (`SinkMode::groups_directories` in the scanner), so it is applied before any entry
622///   in the directory is classified, as the builder applies a listing's control before
623///   its children. A listing that fills a batch before its `.gitignore` is listed has
624///   that file read directly, only under the exact name a listing accepts, and the read
625///   stands for the listing; on a tree nothing modifies during the walk it is the same
626///   file with the same bytes.
627/// - One consumer applies every control in arrival order, as the builder's one consumer
628///   does. Which files a budget refuses when several compete for it depends on that
629///   order on both routes; with one worker the order is the same on both, and with
630///   several it is an order the index could also have met. Whether any file is refused
631///   does not depend on it, because a cold walk only adds charges.
632///
633/// **Why it keeps no entries.** A parent's classification is final once its own
634/// observation is folded, since every ancestor's was folded first. So the fold keeps only
635/// the ignored directories whose parent is not ignored, which head every ignored subtree,
636/// and a parent is ignored exactly when one of them is its ancestor or itself.
637///
638/// **What it withholds.** The share, where the index withholds the root's: when a control
639/// file was refused, because it may have held negations as well as ignore rules, or could
640/// not be read. The coverage it reports is the table's, refusals included.
641struct SummaryFold {
642    /// Every entry the walk retained.
643    all: SummaryRow,
644    /// The classifier, when the scan observes `.gitignore`.
645    controls: Option<SummaryControls>,
646}
647
648/// What a classifying [`SummaryFold`] keeps: the control table, the heads of ignored
649/// subtrees, and the tallies of what no rule ignores.
650struct SummaryControls {
651    table: crate::control::ControlTable,
652    /// Ignored directories whose parent is not ignored.
653    ///
654    /// Unbounded by design: no head lies below another, so the set holds one path per
655    /// separately ignored subtree, which is what classifying an entry needs. It grows with
656    /// such subtrees, not with the entries in them: a tree of many small ignored
657    /// directories, each under a directory that is not ignored, is the case that makes it
658    /// large. The RSS evidence so far (exp-170, exp-171) is on trees with few heads.
659    ignored_heads: std::collections::HashSet<std::path::PathBuf>,
660    /// The parent last looked up, and whether it is ignored. A listing's entries mostly
661    /// arrive together, so this answers nearly all of them.
662    parent: Option<(std::path::PathBuf, bool)>,
663    /// The controls governing the parent last classified under, resolved once for its
664    /// listing (H163) and dropped whenever the table changes.
665    chain: Option<(std::path::PathBuf, crate::control::ControlChain)>,
666    /// Every entry no rule ignores, as the index's `unignored` partition.
667    unignored: crate::index::RollUpScalars,
668    /// The first control observation the table rejected, which fails the report as it
669    /// fails the index build.
670    rejected: Option<Error>,
671}
672
673impl SummaryFold {
674    fn new(config: &crate::ScanConfig) -> Self {
675        Self {
676            all: SummaryRow::default(),
677            controls: config.read_controls.then(|| SummaryControls {
678                table: crate::control::ControlTable::with_limits(config.control_limits),
679                ignored_heads: std::collections::HashSet::new(),
680                parent: None,
681                chain: None,
682                unignored: crate::index::RollUpScalars::default(),
683                rejected: None,
684            }),
685        }
686    }
687
688    fn observe(&mut self, observed: &crate::ObservationOp) {
689        match &observed.op {
690            crate::Op::Upsert { path, kind, attrs } => {
691                match kind {
692                    EntryKind::File => {
693                        self.all.files += 1;
694                        self.all.bytes += attrs.size;
695                        self.all.allocated += attrs.allocated;
696                        self.all.newest_mtime_ns = Some(
697                            self.all
698                                .newest_mtime_ns
699                                .map_or(attrs.mtime_ns, |current| current.max(attrs.mtime_ns)),
700                        );
701                    }
702                    EntryKind::Dir => self.all.dirs += 1,
703                    EntryKind::Symlink | EntryKind::Other => {}
704                }
705                let Some(controls) = &mut self.controls else { return };
706                if controls.classify(path, *kind) {
707                    return;
708                }
709                // The index's contribution of an unignored entry: a file's sizes, one
710                // directory, nothing for any other kind.
711                match kind {
712                    EntryKind::File => {
713                        controls.unignored.files += 1;
714                        controls.unignored.bytes += attrs.size;
715                        controls.unignored.allocated += attrs.allocated;
716                    }
717                    EntryKind::Dir => controls.unignored.dirs += 1,
718                    EntryKind::Symlink | EntryKind::Other => {}
719                }
720            }
721            crate::Op::ControlUpsert { path, source } => {
722                if let Some(controls) = &mut self.controls {
723                    controls.chain = None;
724                    let admitted = controls.table.upsert(path, source.clone()).map(drop);
725                    controls.record(admitted);
726                }
727            }
728            crate::Op::ControlRemove { path } => {
729                if let Some(controls) = &mut self.controls {
730                    controls.chain = None;
731                    let removed = controls.table.remove(path).map(drop);
732                    controls.record(removed);
733                }
734            }
735            // A cold walk observes what is there; it neither removes nor invalidates.
736            crate::Op::Remove { .. } | crate::Op::InvalidateSubtree { .. } => {}
737        }
738    }
739
740    /// The summary row, the control coverage, and whether the row withholds its ignored
741    /// share because a governing rule could not be verified.
742    ///
743    /// `errors` are the walk's, normalized, as the index records them.
744    fn finish(
745        self,
746        root: &std::path::Path,
747        errors: &[Error],
748    ) -> Result<(SummaryRow, crate::control::ControlCoverage, bool)> {
749        let Some(controls) = self.controls else {
750            return Ok((
751                SummaryRow { ignored: None, ..self.all },
752                crate::control::ControlCoverage::NotObserved,
753                false,
754            ));
755        };
756        if let Some(error) = controls.rejected {
757            return Err(error);
758        }
759        let unreadable =
760            errors.iter().any(|error| crate::control::unreadable_control(root, error).is_some());
761        let verified = controls.table.refused_len() == 0 && !unreadable;
762        let all = crate::index::RollUpScalars {
763            files: self.all.files,
764            dirs: self.all.dirs,
765            bytes: self.all.bytes,
766            allocated: self.all.allocated,
767            newest_mtime_ns: self.all.newest_mtime_ns.unwrap_or_default(),
768        };
769        let summary = SummaryRow {
770            ignored: verified.then(|| crate::query::IgnoredTally::between(all, controls.unignored)),
771            ..self.all
772        };
773        Ok((
774            summary,
775            crate::control::ControlCoverage::Observed(controls.table.observation()),
776            !verified,
777        ))
778    }
779}
780
781impl SummaryControls {
782    /// Whether the entry at `path` is ignored, decided as
783    /// `DetachedIndexBuilder::push_directory` decides it: an entry below an ignored
784    /// directory is ignored, one in a table with no rules is not, and otherwise the
785    /// deepest control with an opinion decides.
786    fn classify(&mut self, path: &std::path::Path, kind: EntryKind) -> bool {
787        // No rule and no ignored subtree yet, as in every tree without a `.gitignore`:
788        // nothing is ignored, and the parent need not even be derived.
789        if self.ignored_heads.is_empty() && self.table.is_empty() {
790            return false;
791        }
792        let parent = path.parent().unwrap_or_else(|| std::path::Path::new(""));
793        let parent_ignored = self.parent_ignored(parent);
794        let ignored = if parent_ignored || self.table.is_empty() {
795            parent_ignored
796        } else if let Some(name) = path.file_name() {
797            if !matches!(&self.chain, Some((cached, _)) if cached == parent) {
798                self.chain = Some((parent.to_path_buf(), self.table.chain_for(parent)));
799            }
800            let (_, chain) = self.chain.as_ref().expect("the chain was just resolved");
801            chain.is_ignored(parent, name.as_encoded_bytes(), kind.is_dir())
802        } else {
803            self.table.matcher_for(path).is_ignored(kind.is_dir())
804        };
805        if ignored && !parent_ignored && kind.is_dir() {
806            self.ignored_heads.insert(path.to_path_buf());
807        }
808        ignored
809    }
810
811    fn parent_ignored(&mut self, parent: &std::path::Path) -> bool {
812        if self.ignored_heads.is_empty() {
813            return false;
814        }
815        if let Some((cached, ignored)) = &self.parent {
816            if cached == parent {
817                return *ignored;
818            }
819        }
820        let ignored = parent.ancestors().any(|ancestor| self.ignored_heads.contains(ancestor));
821        self.parent = Some((parent.to_path_buf(), ignored));
822        ignored
823    }
824
825    fn record(&mut self, applied: Result<()>) {
826        if let Err(error) = applied {
827            self.rejected.get_or_insert(error);
828        }
829    }
830}
831
832#[cfg(test)]
833mod tests {
834    use std::fs;
835    use std::path::{Path, PathBuf};
836
837    use super::*;
838    use crate::query::{IgnoredEntries, Pattern, Query, Section};
839    use crate::{OpenFixture, ScanConfig};
840
841    #[test]
842    fn total_throughput_uses_one_elapsed_sample_and_selected_size() {
843        use crate::query::SizeMetric;
844        let work = PerformanceSummary {
845            walked_files: 200,
846            walked_bytes: 4_000_000_000,
847            walked_allocated: 1_000_000_000,
848            ..PerformanceSummary::default()
849        };
850        assert_eq!(
851            work.total_throughput(std::time::Duration::from_secs(2), SizeMetric::Apparent),
852            "100 files/s (1.863 GiB/s)"
853        );
854        assert_eq!(
855            work.total_throughput(std::time::Duration::from_secs(2), SizeMetric::Allocated),
856            "100 files/s (0.466 GiB/s)"
857        );
858        assert_eq!(
859            work.total_throughput(std::time::Duration::ZERO, SizeMetric::Apparent),
860            "throughput unavailable"
861        );
862        assert_eq!(
863            PerformanceSummary::default()
864                .total_throughput(std::time::Duration::from_secs(1), SizeMetric::Apparent),
865            "0 files/s (0.000 GiB/s)"
866        );
867        assert_eq!(
868            throughput_rates(12_345, 3 * (1_u64 << 30), std::time::Duration::from_secs(2)),
869            Some(("6,172".to_owned(), "1.500".to_owned()))
870        );
871    }
872
873    #[test]
874    #[cfg(unix)]
875    fn an_unreadable_stored_header_never_authorizes_live_replacement() {
876        use std::os::unix::fs::PermissionsExt;
877        if !crate::test_support::require_permission_bits() {
878            return;
879        }
880        let root = tempfile::tempdir().expect("root");
881        let cache = tempfile::tempdir().expect("cache");
882        let snapshot = cache.path().join("snapshot.fdu");
883        fs::write(root.path().join(".gitignore"), b"ignored\n").expect("control");
884        let observed = crate::query::Basis {
885            root: root.path().into(),
886            scope: crate::query::Scope::default(),
887            content: crate::content::AnalysisSet::NONE,
888        };
889        let delivery = Delivery::new(CachePolicy::Auto, Some(snapshot.clone()));
890        crate::open(&observed, &delivery).expect("stronger snapshot");
891        let original = fs::read(&snapshot).expect("original image");
892        let basis = crate::query::Basis {
893            scope: crate::query::Scope { read_controls: false, ..Default::default() },
894            ..observed
895        };
896        let (mut index, _) =
897            crate::open(&basis, &Delivery::new(CachePolicy::Off, None)).expect("fresh blind index");
898        let request = Request::new(basis, Query::default(), SystemTime::now());
899        let plan = plan(&request, &delivery, Route::Refresh).expect("plan");
900        fs::set_permissions(&snapshot, fs::Permissions::from_mode(0o000))
901            .expect("deny header read");
902        let nonwriting = Delivery { cache: CachePolicy::Off, ..delivery.clone() };
903        let nonwriting_plan =
904            super::plan(&request, &nonwriting, Route::Refresh).expect("nonwriting plan");
905        assert!(
906            !crate::persist_index_changes(&index, &nonwriting_plan, true, true)
907                .expect("nonwriting policy never reads the header")
908        );
909        fs::write(root.path().join("fresh.txt"), b"fresh").expect("mutation");
910        crate::refresh(
911            &mut index,
912            &request.basis,
913            &Delivery { cache: CachePolicy::Off, ..delivery.clone() },
914        )
915        .expect("off refresh does not inspect cache state");
916        let result = crate::persist_index_changes(&index, &plan, true, true);
917        fs::set_permissions(&snapshot, fs::Permissions::from_mode(0o600)).expect("restore");
918        assert!(
919            result.is_err(),
920            "a writable parent must not let unknown identity authorize replacement"
921        );
922        assert_eq!(fs::read(&snapshot).expect("retained image"), original);
923    }
924
925    #[test]
926    fn refresh_rejects_another_root_before_mutating_or_persisting() {
927        let a = tempfile::tempdir().expect("root a");
928        let b = tempfile::tempdir().expect("root b");
929        let cache = tempfile::tempdir().expect("cache");
930        let basis = crate::query::Basis {
931            root: a.path().into(),
932            scope: crate::query::Scope::default(),
933            content: crate::content::AnalysisSet::NONE,
934        };
935        let (mut index, _) =
936            crate::open(&basis, &Delivery::new(CachePolicy::Off, None)).expect("open a");
937        fs::write(a.path().join("new"), b"new facts").expect("mutation a");
938        let before = index.clock();
939        let snapshot = cache.path().join("snapshot.fdu");
940        let wrong = crate::query::Basis { root: b.path().into(), ..basis.clone() };
941        let delivery = Delivery::new(CachePolicy::Auto, Some(snapshot.clone()));
942        let error = crate::refresh(&mut index, &wrong, &delivery).expect_err("different root");
943        assert!(matches!(
944            error,
945            Error::InvalidRequest(crate::query::RequestError::RootMismatch { .. })
946        ));
947        assert_eq!(index.clock(), before);
948        assert!(!snapshot.exists());
949        let alias = crate::query::Basis { root: a.path().join("."), ..basis };
950        crate::refresh(&mut index, &alias, &delivery).expect("same root spelling");
951        assert_eq!(index.total().files, 1);
952        #[cfg(unix)]
953        {
954            let link = cache.path().join("root-alias");
955            std::os::unix::fs::symlink(a.path(), &link).expect("root alias");
956            let symlink_basis = crate::query::Basis { root: link, ..alias };
957            crate::refresh(&mut index, &symlink_basis, &delivery)
958                .expect("same canonical root through symlink");
959        }
960    }
961
962    #[test]
963    fn unchanged_refresh_replaces_an_incompatible_stored_baseline() {
964        let root = tempfile::tempdir().expect("root");
965        let other = tempfile::tempdir().expect("other root");
966        let cache = tempfile::tempdir().expect("cache");
967        fs::write(root.path().join("file"), b"retained").expect("file");
968        let basis = crate::query::Basis {
969            root: root.path().into(),
970            scope: crate::query::Scope::default(),
971            content: crate::content::AnalysisSet::NONE,
972        };
973        let delivery = Delivery::new(CachePolicy::Auto, Some(cache.path().join("snapshot.fdu")));
974        for wrong_root in [false, true] {
975            let wrong = if wrong_root {
976                crate::query::Basis { root: other.path().into(), ..basis.clone() }
977            } else {
978                crate::query::Basis {
979                    scope: crate::query::Scope { max_depth: Some(0), ..basis.scope.clone() },
980                    ..basis.clone()
981                }
982            };
983            crate::open(&wrong, &Delivery { cache: CachePolicy::On, ..delivery.clone() })
984                .expect("incompatible snapshot");
985            let (mut index, _) = crate::open(&basis, &Delivery::new(CachePolicy::Off, None))
986                .expect("retained index");
987            let refreshed =
988                crate::refresh(&mut index, &basis, &delivery).expect("refresh reseeds cache");
989            assert!(!refreshed.apply.mutated(), "the existing index was already current");
990            let (cached, _) = crate::open(&basis, &Delivery { stale_ok: true, ..delivery.clone() })
991                .expect("cache-only can now answer");
992            assert_eq!(cached.total().bytes, 8);
993        }
994    }
995
996    #[test]
997    fn refreshed_metadata_and_content_are_visible_to_a_later_cache_only_open() {
998        let root = tempfile::tempdir().expect("root");
999        let cache = tempfile::tempdir().expect("cache");
1000        let path = root.path().join("note.txt");
1001        fs::write(&path, b"old\n").expect("old file");
1002        let basis = crate::query::Basis {
1003            root: root.path().into(),
1004            scope: crate::query::Scope::default(),
1005            content: crate::content::AnalysisSet::NONE.with_lines(),
1006        };
1007        let delivery = Delivery::new(CachePolicy::Auto, Some(cache.path().join("snapshot.fdu")));
1008        let (mut index, _) = crate::open(&basis, &delivery).expect("initial open");
1009        fs::write(&path, b"new longer text\nsecond line\n").expect("mutation");
1010        let refreshed = crate::refresh(&mut index, &basis, &delivery).expect("refresh");
1011        assert!(refreshed.is_complete());
1012        let (cached, report) = crate::open(&basis, &Delivery { stale_ok: true, ..delivery })
1013            .expect("cache-only sees refreshed tiers");
1014        assert_eq!(report.path_taken, OpenPath::CacheOnly);
1015        assert_eq!(cached.total(), index.total());
1016        assert_eq!(
1017            cached.total().bytes,
1018            u64::try_from(b"new longer text\nsecond line\n".len()).expect("length")
1019        );
1020        assert_eq!(report.content_cache.hits, 1);
1021        let original = index
1022            .content()
1023            .expect("fresh content")
1024            .file(Path::new("note.txt"))
1025            .expect("fresh file");
1026        let restored = cached
1027            .content()
1028            .expect("restored content")
1029            .file(Path::new("note.txt"))
1030            .expect("restored file");
1031        assert_eq!(restored, original);
1032    }
1033
1034    /// One root with one file and one empty directory, and a writing delivery whose
1035    /// snapshot lives in its own directory so a test can make that directory read-only.
1036    #[cfg(unix)]
1037    fn owed_persistence_fixture()
1038    -> (tempfile::TempDir, tempfile::TempDir, crate::query::Basis, Delivery) {
1039        let root = tempfile::tempdir().expect("root");
1040        let cache = tempfile::tempdir().expect("cache");
1041        fs::create_dir(root.path().join("locked")).expect("locked dir");
1042        fs::write(root.path().join("first"), b"first").expect("first file");
1043        let basis = crate::query::Basis {
1044            root: root.path().into(),
1045            scope: crate::query::Scope::default(),
1046            content: crate::content::AnalysisSet::NONE,
1047        };
1048        let delivery = Delivery::new(CachePolicy::Auto, Some(cache.path().join("snapshot.fdu")));
1049        (root, cache, basis, delivery)
1050    }
1051
1052    /// The files a cache-only open of `delivery`'s snapshot answers with.
1053    #[cfg(unix)]
1054    fn cached_files(basis: &crate::query::Basis, delivery: &Delivery) -> u64 {
1055        let cache_only = Delivery { stale_ok: true, ..delivery.clone() };
1056        crate::open(basis, &cache_only).expect("cache-only open").0.total().files
1057    }
1058
1059    /// A refresh whose metadata write failed leaves the index holding facts the snapshot
1060    /// lacks; the next refresh must write them even though it changes nothing itself.
1061    ///
1062    /// The metadata write used to be keyed to the pass that ran it: a later pass that
1063    /// mutated nothing wrote nothing, so a snapshot that missed one write missed the
1064    /// facts for good, and cache-only reads answered older facts than the index held.
1065    #[test]
1066    #[cfg(unix)]
1067    fn an_unchanged_refresh_repeats_the_metadata_write_a_failed_refresh_owed() {
1068        use std::os::unix::fs::PermissionsExt;
1069        if !crate::test_support::require_permission_bits() {
1070            return;
1071        }
1072        let (root, cache, basis, delivery) = owed_persistence_fixture();
1073        let (mut index, _) = crate::open(&basis, &delivery).expect("initial open");
1074        assert_eq!(cached_files(&basis, &delivery), 1);
1075
1076        fs::write(root.path().join("second"), b"second").expect("second file");
1077        fs::set_permissions(cache.path(), fs::Permissions::from_mode(0o555)).expect("deny write");
1078        let failed = crate::refresh(&mut index, &basis, &delivery);
1079        fs::set_permissions(cache.path(), fs::Permissions::from_mode(0o755)).expect("restore");
1080        assert!(failed.is_err(), "a read-only cache directory fails the write");
1081        assert_eq!(index.total().files, 2, "the index advanced before the write");
1082        assert_eq!(cached_files(&basis, &delivery), 1, "the failed write left the old image");
1083
1084        let unchanged = crate::refresh(&mut index, &basis, &delivery).expect("unchanged refresh");
1085        assert!(unchanged.is_complete());
1086        assert!(!unchanged.apply.mutated(), "nothing changed between the passes");
1087        assert_eq!(cached_files(&basis, &delivery), 2, "the owed write ran");
1088
1089        // Paid once: the next unchanged pass has nothing to write.
1090        let snapshot = delivery.cache_path.as_deref().expect("path");
1091        let written = fs::metadata(snapshot).expect("snapshot").modified().expect("mtime");
1092        crate::refresh(&mut index, &basis, &delivery).expect("settled refresh");
1093        assert_eq!(fs::metadata(snapshot).expect("snapshot").modified().expect("mtime"), written);
1094    }
1095
1096    /// The same debt when the failed write is the one a warm `open` started: a caller
1097    /// keeping the index through [`crate::open_with_pending_save`] keeps the debt too.
1098    #[test]
1099    #[cfg(unix)]
1100    fn an_unchanged_refresh_repeats_the_metadata_write_a_failed_open_owed() {
1101        use std::os::unix::fs::PermissionsExt;
1102        if !crate::test_support::require_permission_bits() {
1103            return;
1104        }
1105        let (root, cache, basis, delivery) = owed_persistence_fixture();
1106        crate::open(&basis, &delivery).expect("complete open writes the snapshot");
1107        fs::write(root.path().join("second"), b"second").expect("second file");
1108        fs::set_permissions(cache.path(), fs::Permissions::from_mode(0o555)).expect("deny write");
1109        let opened = crate::open_with_pending_save(&basis, &delivery);
1110        // Joined before the directory is writable again: the write runs in the background.
1111        let outcome = opened.map(|(index, report, pending)| (index, report, pending.join()));
1112        fs::set_permissions(cache.path(), fs::Permissions::from_mode(0o755)).expect("restore");
1113        let (index, report, joined) = outcome.expect("the open itself succeeds");
1114        assert_eq!(report.path_taken, OpenPath::WarmRevalidate);
1115        assert!(joined.is_err(), "the startup write failed");
1116        let mut index = std::sync::Arc::into_inner(index).expect("the writer released the index");
1117        assert_eq!(cached_files(&basis, &delivery), 1);
1118
1119        let unchanged = crate::refresh(&mut index, &basis, &delivery).expect("unchanged refresh");
1120        assert!(!unchanged.apply.mutated(), "nothing changed between the passes");
1121        assert_eq!(cached_files(&basis, &delivery), 2, "the owed write ran");
1122    }
1123
1124    /// A partial refresh cannot write the entry tier; once the tree is readable again a
1125    /// complete refresh delivers the partial pass's facts to the snapshot.
1126    ///
1127    /// Restoring the directory's permissions updates its change time, so on a POSIX host
1128    /// the recovering pass reports that directory as updated and would write on its own
1129    /// account. The failed-write tests above are the ones that prove the debt is carried;
1130    /// this one guards that a partial pass's verified facts reach the snapshot at all.
1131    #[test]
1132    #[cfg(unix)]
1133    fn a_complete_refresh_persists_the_facts_a_partial_refresh_could_not() {
1134        use std::os::unix::fs::PermissionsExt;
1135        if !crate::test_support::require_permission_bits() {
1136            return;
1137        }
1138        let (root, _cache, basis, delivery) = owed_persistence_fixture();
1139        let locked = root.path().join("locked");
1140        let (mut index, _) = crate::open(&basis, &delivery).expect("initial open");
1141        assert_eq!(index.total().files, 1);
1142
1143        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).expect("deny read");
1144        fs::write(root.path().join("second"), b"second").expect("second file");
1145        let partial = crate::refresh(&mut index, &basis, &delivery);
1146        fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).expect("restore");
1147        let partial = partial.expect("partial refresh");
1148        assert!(!partial.is_complete(), "the locked directory made the pass partial");
1149        assert!(partial.apply.mutated(), "the second file was inserted");
1150        assert_eq!(index.total().files, 2);
1151        assert_eq!(cached_files(&basis, &delivery), 1, "a partial pass never writes entries");
1152
1153        let complete = crate::refresh(&mut index, &basis, &delivery).expect("complete refresh");
1154        assert!(complete.is_complete(), "{:?}", complete.scan.errors);
1155        assert_eq!(cached_files(&basis, &delivery), 2, "the complete pass wrote the facts");
1156    }
1157
1158    #[test]
1159    fn cache_only_refusals_name_location_root_and_absence_separately() {
1160        let root = tempfile::tempdir().expect("root");
1161        let other = tempfile::tempdir().expect("other root");
1162        let cache = tempfile::tempdir().expect("cache");
1163        let basis = crate::query::Basis {
1164            root: root.path().into(),
1165            scope: crate::query::Scope::default(),
1166            content: crate::content::AnalysisSet::NONE,
1167        };
1168        let snapshot = cache.path().join("snapshot.fdu");
1169        let message = |delivery: &Delivery| {
1170            crate::open(&basis, delivery).expect_err("cache-only refusal").to_string()
1171        };
1172        let no_location = message(&Delivery::stale_ok(None));
1173        assert!(no_location.contains("no cache location"), "{no_location}");
1174        assert!(!no_location.contains("`on`"), "no write can succeed without a location");
1175        let missing = message(&Delivery::stale_ok(Some(snapshot.clone())));
1176        assert!(missing.contains("no usable snapshot"), "{missing}");
1177        assert!(missing.contains("with the `on` cache policy"), "{missing}");
1178        let other_basis = crate::query::Basis { root: other.path().into(), ..basis.clone() };
1179        crate::open(&other_basis, &Delivery::new(CachePolicy::Auto, Some(snapshot.clone())))
1180            .expect("other snapshot");
1181        let wrong_root = message(&Delivery::stale_ok(Some(snapshot)));
1182        assert!(wrong_root.contains("different root"), "{wrong_root}");
1183    }
1184
1185    #[test]
1186    fn route_delivery_matrix_rejects_contracts_the_route_cannot_execute() {
1187        let basis = crate::query::Basis {
1188            root: ".".into(),
1189            scope: crate::query::Scope::default(),
1190            content: crate::content::AnalysisSet::NONE,
1191        };
1192        let request = Request::new(basis, Query::default(), SystemTime::now());
1193        for delivery in Delivery::enumerate() {
1194            for route in
1195                [Route::OneShot, Route::Retained, Route::Refresh, Route::Watch, Route::Opened]
1196            {
1197                let result = plan(&request, &delivery, route);
1198                let forbidden = delivery.stale_ok
1199                    && (delivery.watch.is_some()
1200                        || matches!(route, Route::Watch | Route::Refresh)
1201                        || delivery.cache == CachePolicy::Off)
1202                    || route == Route::Opened
1203                        && (delivery.cache != CachePolicy::Off
1204                            || delivery.watch.is_some()
1205                            || delivery.accept_partial);
1206                assert_eq!(result.is_err(), forbidden, "{route:?} {delivery:?}");
1207                if let Ok(plan) = result {
1208                    if route == Route::Opened {
1209                        assert_eq!(plan.load(), Load::None);
1210                        assert_eq!(plan.verify(), Verify::Filesystem);
1211                    }
1212                }
1213            }
1214        }
1215    }
1216
1217    #[test]
1218    fn an_opened_root_refuses_the_scheduling_it_would_otherwise_drop() {
1219        // `OpenOptions::into_parts` runs one breadth-first producer whatever the delivery
1220        // says, and an opened root has no single answer for `accept_partial` to classify.
1221        // A value the route would silently ignore is refused at planning instead, and the
1222        // same values plan on a route that executes them.
1223        let basis = crate::query::Basis {
1224            root: ".".into(),
1225            scope: crate::query::Scope::default(),
1226            content: crate::content::AnalysisSet::NONE,
1227        };
1228        let request = Request::new(basis, Query::default(), SystemTime::now());
1229        let default = Delivery::new(CachePolicy::Off, None);
1230        let plan_opened = plan(&request, &default, Route::Opened).expect("defaults plan");
1231        assert_eq!(plan_opened.delivery().batch_size, default.batch_size);
1232        let unhonored = [
1233            (
1234                "scan workers",
1235                Delivery {
1236                    workers: crate::query::Workers { scan: Some(4), ..default.workers },
1237                    ..default.clone()
1238                },
1239            ),
1240            (
1241                "depth-first order",
1242                Delivery { order: crate::ScanOrder::DepthFirst, ..default.clone() },
1243            ),
1244            ("accept partial", Delivery { accept_partial: true, ..default.clone() }),
1245        ];
1246        for (case, delivery) in unhonored {
1247            let refused = plan(&request, &delivery, Route::Opened).expect_err(case);
1248            assert!(
1249                matches!(
1250                    refused,
1251                    crate::query::RequestError::DeliveryUnsupported { route: "opened", .. }
1252                ),
1253                "{case}: {refused}"
1254            );
1255            plan(&request, &delivery, Route::Retained)
1256                .unwrap_or_else(|error| panic!("{case} executes on a retained route: {error}"));
1257        }
1258        // A larger batch is honored, so it is not refused.
1259        let batched = Delivery { batch_size: default.batch_size * 2, ..default };
1260        let plan_batched = plan(&request, &batched, Route::Opened).expect("batch size plans");
1261        assert_eq!(plan_batched.delivery().batch_size, batched.batch_size);
1262    }
1263
1264    #[test]
1265    fn tier_writes_depend_only_on_authorization_and_observed_facts() {
1266        // Which routes are authorized is `plan`'s decision, pinned by
1267        // `auto_persists_where_a_later_request_reads_what_it_stores`; this pins what each
1268        // tier does with the authorization it was given.
1269        let routes = [Route::OneShot, Route::Retained, Route::Refresh, Route::Watch, Route::Opened];
1270        for (delivery, persist) in
1271            Delivery::enumerate().flat_map(|delivery| [(delivery.clone(), false), (delivery, true)])
1272        {
1273            for bits in 0_u8..64 {
1274                let facts = RunFacts {
1275                    entries_verified: bits & 1 != 0,
1276                    entries_changed: bits & 2 != 0,
1277                    content_changed: bits & 4 != 0,
1278                    content_requested: bits & 8 != 0,
1279                    projected: bits & 16 != 0,
1280                    paired_entries: bits & 32 != 0,
1281                };
1282                let allowed = persist;
1283                let expected = SaveTargets {
1284                    metadata: allowed
1285                        && facts.entries_verified
1286                        && facts.entries_changed
1287                        && !facts.projected,
1288                    content: allowed
1289                        && facts.content_requested
1290                        && facts.content_changed
1291                        && (facts.entries_verified || facts.paired_entries),
1292                };
1293                for route in routes {
1294                    let plan = Plan {
1295                        basis: crate::query::Basis {
1296                            root: ".".into(),
1297                            scope: crate::query::Scope::default(),
1298                            content: crate::content::AnalysisSet::NONE,
1299                        },
1300                        route,
1301                        retained: RetainedState::FullIndex,
1302                        load: Load::Snapshot,
1303                        verify: Verify::Filesystem,
1304                        persist,
1305                        delivery: delivery.clone(),
1306                    };
1307                    assert_eq!(plan.writes(facts), expected, "{route:?} {delivery:?} {facts:?}");
1308                    let unavailable = Plan {
1309                        delivery: Delivery { cache_path: None, ..delivery.clone() },
1310                        ..plan
1311                    };
1312                    assert!(unavailable.writes(facts).none());
1313                }
1314            }
1315        }
1316    }
1317
1318    fn planned(config: &OpenFixture, query: &Query) -> Plan {
1319        let (request, delivery) = split(Path::new("."), config, query);
1320        plan(&request, &delivery, Route::OneShot).expect("valid plan")
1321    }
1322
1323    fn summary_query() -> Query {
1324        Query { views: vec![ViewSpec::Summary], ..Query::default() }
1325    }
1326
1327    /// The request and the delivery a test's `OpenFixture` spells, split the way the two
1328    /// models now divide it: what the answer says, and how it is carried out.
1329    fn split(root: &Path, config: &OpenFixture, query: &Query) -> (Request, Delivery) {
1330        let (basis, delivery) = config.split(root);
1331        (Request::new(basis, query.clone(), std::time::UNIX_EPOCH), delivery)
1332    }
1333
1334    /// [`prepare_report`] as these tests ask for it: one configuration, one query.
1335    fn prepared(
1336        root: &Path,
1337        config: &OpenFixture,
1338        query: &Query,
1339    ) -> Result<(Report, PendingSave, PerformanceSummary)> {
1340        let (request, delivery) = split(root, config, query);
1341        prepare_report(&request, &delivery)
1342    }
1343
1344    /// [`prepared`], keeping the scan diagnostics.
1345    fn prepared_with_diagnostics(
1346        root: &Path,
1347        config: &OpenFixture,
1348        query: &Query,
1349    ) -> Result<(Report, PendingSave, PerformanceSummary, Option<crate::scan::ScanDiagnostics>)>
1350    {
1351        let (request, delivery) = split(root, config, query);
1352        prepare_report_with_scan_diagnostics(&request, &delivery)
1353    }
1354
1355    fn config(policy: CachePolicy, cache_path: Option<PathBuf>) -> OpenFixture {
1356        OpenFixture { scan: ScanConfig::default(), cache_path, policy, ..OpenFixture::default() }
1357    }
1358
1359    /// [`config`] with `.gitignore` observation turned off.
1360    fn blind(policy: CachePolicy, cache_path: Option<PathBuf>) -> OpenFixture {
1361        OpenFixture {
1362            scan: ScanConfig { read_controls: false, ..ScanConfig::default() },
1363            ..config(policy, cache_path)
1364        }
1365    }
1366
1367    fn controls_config(
1368        policy: CachePolicy,
1369        cache_path: PathBuf,
1370        read_controls: bool,
1371    ) -> OpenFixture {
1372        OpenFixture {
1373            scan: ScanConfig { read_controls, ..ScanConfig::default() },
1374            cache_path: Some(cache_path),
1375            policy,
1376            ..OpenFixture::default()
1377        }
1378    }
1379
1380    /// `fixture`, answered from its snapshot alone.
1381    fn stale(fixture: OpenFixture) -> OpenFixture {
1382        OpenFixture { stale_ok: true, ..fixture }
1383    }
1384
1385    fn seed_controls_snapshot(root: &Path, cache_path: PathBuf) {
1386        fs::write(root.join(".gitignore"), b"ignored.log\n").expect("control file");
1387        fs::write(root.join("ignored.log"), b"ignored").expect("ignored file");
1388        crate::open_fixture(root, &controls_config(CachePolicy::Auto, cache_path, true))
1389            .expect("seed controls-on snapshot");
1390    }
1391
1392    #[test]
1393    fn planner_uses_compact_state_only_when_the_request_proves_it_is_sufficient() {
1394        let off = blind(CachePolicy::Off, Some(PathBuf::from("unused.fdu")));
1395        assert_eq!(planned(&off, &summary_query()).retained, RetainedState::Summary);
1396
1397        for policy in [CachePolicy::Auto, CachePolicy::On] {
1398            let unavailable = blind(policy, None);
1399            assert_eq!(planned(&unavailable, &summary_query()).retained, RetainedState::Summary);
1400        }
1401
1402        let mut several_views = summary_query();
1403        several_views.views.push(ViewSpec::Types);
1404        assert_eq!(planned(&off, &several_views).retained, RetainedState::FullIndex);
1405
1406        let mut filtered = summary_query();
1407        filtered.selection.include.push(Pattern::parse("*.rs").expect("pattern"));
1408        assert_eq!(planned(&off, &filtered).retained, RetainedState::FullIndex);
1409
1410        // Changed deliberately by fdu-1ovb. The reducer classifies each entry with the
1411        // control table the index would hold, so the default summary, whose row carries an
1412        // ignored share, no longer needs the index; this assertion used to expect
1413        // `FullIndex`. `compact_summary_equals_the_indexed_summary_under_every_control_case`
1414        // is what licenses the change.
1415        let observing = config(CachePolicy::Off, None);
1416        assert!(observing.scan.read_controls, "observation is the default");
1417        assert_eq!(planned(&observing, &summary_query()).retained, RetainedState::Summary);
1418        // Selecting by ignored state is a filter, and a narrowed population is one retained
1419        // in the scope as well: both still need the index, whose traversal answers them.
1420        let mut by_ignored = summary_query();
1421        by_ignored.selection.ignored = IgnoredEntries::Exclude;
1422        assert_eq!(planned(&observing, &by_ignored).retained, RetainedState::FullIndex);
1423        for population in [IgnoredEntries::Exclude, IgnoredEntries::Only] {
1424            let narrowed = OpenFixture {
1425                scan: ScanConfig { population, ..ScanConfig::default() },
1426                ..config(CachePolicy::Off, None)
1427            };
1428            let mut query = summary_query();
1429            query.selection.ignored = population;
1430            assert_eq!(planned(&narrowed, &query).retained, RetainedState::FullIndex);
1431        }
1432    }
1433
1434    #[test]
1435    fn an_available_snapshot_does_not_force_the_index_for_a_metadata_summary() {
1436        // A loaded snapshot cannot save the work an unfiltered metadata summary is
1437        // already doing: revalidation stats every entry regardless, so retaining the
1438        // index and writing it back is additive cost with nothing to amortise it. The
1439        // compact tier stays selected so the common one-shot totals request pays for a
1440        // walk and nothing else.
1441        let cached = blind(CachePolicy::Auto, Some(PathBuf::from("cache.fdu")));
1442        assert_eq!(
1443            planned(&cached, &summary_query()).retained,
1444            RetainedState::Summary,
1445            "a present snapshot must not force the index"
1446        );
1447    }
1448
1449    #[test]
1450    fn deliveries_whose_intent_is_the_snapshot_itself_still_retain_the_index() {
1451        // These two are not cost decisions. A stale answer must come without touching the
1452        // tree, so it has no scan to reduce; `On` is an explicit request to leave a
1453        // current snapshot, which means materialising the index that gets written.
1454        let cached = || blind(CachePolicy::Auto, Some(PathBuf::from("cache.fdu")));
1455        for (name, delivery) in [
1456            ("stale", stale(cached())),
1457            ("on", OpenFixture { policy: CachePolicy::On, ..cached() }),
1458        ] {
1459            assert_eq!(
1460                planned(&delivery, &summary_query()).retained,
1461                RetainedState::FullIndex,
1462                "{name} needs the index to honour its contract"
1463            );
1464        }
1465    }
1466
1467    #[test]
1468    fn a_one_shot_metadata_query_does_not_read_the_snapshot_it_cannot_use() {
1469        // Revalidation stats every entry regardless of what the snapshot holds, so for
1470        // a metadata query the load and the reconciliation against it are additive cost:
1471        // measured on macOS/APFS over 494,031 entries, warm revalidation cost 4.8 s
1472        // against 3.6 s for the cold path. This holds for every view, not just the
1473        // compact summary — the tree default was the measured case.
1474        let mut tree_query = summary_query();
1475        tree_query.views = vec![ViewSpec::Tree];
1476        for policy in [CachePolicy::Auto, CachePolicy::On] {
1477            let cached = config(policy, Some(PathBuf::from("cache.fdu")));
1478            assert!(
1479                planned(&cached, &tree_query).load != Load::Snapshot,
1480                "{policy:?} must not pay for a read that saves no work"
1481            );
1482        }
1483    }
1484
1485    #[test]
1486    fn the_snapshot_is_read_where_reading_pays_or_is_the_contract() {
1487        // A stale answer comes from the snapshot; reading it is the request itself.
1488        let only = stale(config(CachePolicy::Auto, Some(PathBuf::from("cache.fdu"))));
1489        assert_eq!(planned(&only, &summary_query()).load, Load::Snapshot);
1490
1491        // Analysis reuses the content sidecar, which avoids re-reading file bodies —
1492        // the one measured case where a warm read wins (639 ms to 325 ms).
1493        let analyzed = OpenFixture {
1494            analysis: crate::content::AnalysisRequest {
1495                profile: crate::content::AnalysisSet::NONE.with_code(),
1496                ..Default::default()
1497            },
1498            ..config(CachePolicy::Auto, Some(PathBuf::from("cache.fdu")))
1499        };
1500        assert_eq!(planned(&analyzed, &summary_query()).load, Load::Snapshot);
1501
1502        // `Off` never reads by definition.
1503        let never = config(CachePolicy::Off, Some(PathBuf::from("cache.fdu")));
1504        assert_eq!(planned(&never, &summary_query()).load, Load::None);
1505    }
1506
1507    #[test]
1508    fn auto_persists_where_a_later_request_reads_what_it_stores() {
1509        // The policy table in one place. A one-shot metadata report under `Auto` leaves
1510        // nothing: no later one-shot report reads it, and it cost 0.26 s of a 1.51 s
1511        // default run on a million-entry Linux tree. Analysis keeps its sidecar and the
1512        // snapshot it pairs with; retained routes are their own later reader. `On` writes
1513        // everywhere a verified answer is produced, `Off` and a stale answer nowhere.
1514        let cache = Some(PathBuf::from("cache.fdu"));
1515        let tree = Query { views: vec![ViewSpec::Tree], ..Query::default() };
1516        let persists = |fixture: &OpenFixture, query: &Query, route: Route| {
1517            let (request, delivery) = split(Path::new("."), fixture, query);
1518            plan(&request, &delivery, route).expect("valid plan").persists()
1519        };
1520        for (policy, one_shot, analysis, retained) in [
1521            (CachePolicy::Auto, false, true, true),
1522            (CachePolicy::On, true, true, true),
1523            (CachePolicy::Off, false, false, false),
1524        ] {
1525            let metadata = config(policy, cache.clone());
1526            assert_eq!(persists(&metadata, &tree, Route::OneShot), one_shot, "{policy:?}");
1527            assert_eq!(
1528                persists(&analyzing(metadata.clone()), &tree, Route::OneShot),
1529                analysis,
1530                "{policy:?} with analysis"
1531            );
1532            for route in [Route::Retained, Route::Refresh, Route::Watch] {
1533                assert_eq!(persists(&metadata, &tree, route), retained, "{policy:?} {route:?}");
1534            }
1535        }
1536        let stale_answer = stale(config(CachePolicy::On, cache));
1537        assert!(!persists(&stale_answer, &tree, Route::OneShot), "a stale answer writes nothing");
1538        assert!(!persists(&stale_answer, &tree, Route::Retained), "a stale answer writes nothing");
1539    }
1540
1541    #[test]
1542    fn an_analysis_request_never_selects_the_compact_summary_tier() {
1543        // Analysis reads file contents keyed by retained entries and writes its own
1544        // sidecar, so the aggregate-only tier cannot answer it even though the request
1545        // otherwise looks like the uncached unfiltered summary the planner compacts.
1546        let off = blind(CachePolicy::Off, None);
1547        assert_eq!(planned(&off, &summary_query()).retained, RetainedState::Summary);
1548
1549        for profile in [
1550            crate::content::AnalysisSet::NONE.with_lines(),
1551            crate::content::AnalysisSet::NONE.with_code(),
1552            crate::content::AnalysisSet::NONE.with_words(),
1553            crate::content::AnalysisSet::ALL,
1554        ] {
1555            let analyzed = OpenFixture {
1556                analysis: crate::content::AnalysisRequest { profile, ..Default::default() },
1557                ..blind(CachePolicy::Off, None)
1558            };
1559            assert_eq!(
1560                planned(&analyzed, &summary_query()).retained,
1561                RetainedState::FullIndex,
1562                "{profile:?} must retain the index"
1563            );
1564        }
1565    }
1566
1567    #[test]
1568    fn a_repeated_one_shot_report_scans_cold_while_open_still_revalidates() {
1569        // The same snapshot, two consumers, two right answers. A one-shot report cannot
1570        // amortise a snapshot load, so its second run scans cold again; a caller holding
1571        // the index through `open` amortises it across everything that follows, so its
1572        // second open still takes the warm path. Both report truthfully.
1573        let root = tempfile::tempdir().expect("tempdir");
1574        fs::write(root.path().join("file.txt"), b"contents").expect("file");
1575        let cache = tempfile::tempdir().expect("cache dir");
1576        let auto = config(CachePolicy::Auto, Some(cache.path().join("cache.fdu")));
1577        let on = OpenFixture { policy: CachePolicy::On, ..auto.clone() };
1578        let mut tree_query = summary_query();
1579        tree_query.views = vec![ViewSpec::Tree];
1580
1581        let (first, pending, _) = prepared(root.path(), &on, &tree_query).expect("first report");
1582        pending.join().expect("first save");
1583        assert_eq!(first.provenance.source, ReportSource::ColdScan);
1584        assert!(on.cache_path.as_deref().expect("path").exists(), "`on` persists");
1585
1586        let (second, pending, performance) =
1587            prepared(root.path(), &auto, &tree_query).expect("second report");
1588        pending.join().expect("second save");
1589        assert_eq!(
1590            second.provenance.source,
1591            ReportSource::ColdScan,
1592            "a repeated one-shot must not pay for a read that saves no work"
1593        );
1594        assert_eq!(performance.walked_files, 1, "the walk still happened");
1595
1596        // A default report and a default `open` both observe control state, so they share
1597        // one snapshot scope and the `open` starts from the report's snapshot.
1598        let (_, open_report) = crate::open_fixture(root.path(), &auto).expect("library open");
1599        assert_eq!(
1600            open_report.path_taken,
1601            OpenPath::WarmRevalidate,
1602            "a caller holding the index still amortises the load"
1603        );
1604    }
1605
1606    #[test]
1607    fn a_stale_answer_reads_what_an_on_report_leaves_and_auto_leaves_nothing() {
1608        // `auto` skips the write a one-shot metadata report cannot use, so a stale answer
1609        // after it has nothing to read and says how to leave something; `on` is that way,
1610        // and what it leaves is answered from without touching the tree.
1611        let root = tempfile::tempdir().expect("tempdir");
1612        fs::write(root.path().join("file.txt"), b"contents").expect("file");
1613        let cache = tempfile::tempdir().expect("cache dir");
1614        let auto = config(CachePolicy::Auto, Some(cache.path().join("cache.fdu")));
1615        let mut tree_query = summary_query();
1616        tree_query.views = vec![ViewSpec::Tree];
1617
1618        let (_, pending, _) = prepared(root.path(), &auto, &tree_query).expect("report");
1619        assert!(!pending.writes_metadata(), "`auto` starts no metadata write");
1620        pending.join().expect("nothing to save");
1621        assert!(!cache.path().join("cache.fdu").exists(), "`auto` leaves nothing");
1622        let only = stale(config(CachePolicy::Auto, Some(cache.path().join("cache.fdu"))));
1623        let missing = prepared(root.path(), &only, &tree_query).expect_err("nothing to read");
1624        assert!(missing.to_string().contains("with the `on` cache policy"), "{missing}");
1625
1626        let on = OpenFixture { policy: CachePolicy::On, ..auto };
1627        let (_, pending, _) = prepared(root.path(), &on, &tree_query).expect("report");
1628        pending.join().expect("save");
1629
1630        let (from_cache, pending, performance, diagnostics) =
1631            prepared_with_diagnostics(root.path(), &only, &tree_query).expect("cache-only report");
1632        pending.join().expect("no save");
1633        assert_eq!(from_cache.provenance.source, ReportSource::CacheOnly);
1634        assert_eq!(performance.walked_files, 0, "cache-only never touches the tree");
1635        assert!(diagnostics.is_none(), "a cache-only open has no scan trace");
1636    }
1637
1638    #[test]
1639    fn controls_on_snapshot_projects_to_an_equivalent_controls_off_cache_only_report() {
1640        let root = tempfile::tempdir().expect("tempdir");
1641        let cache = tempfile::tempdir().expect("cache dir");
1642        let cache_path = cache.path().join("cache.fdu");
1643        fs::create_dir(root.path().join("src")).expect("source dir");
1644        fs::write(root.path().join("src/lib.rs"), b"library").expect("source file");
1645        seed_controls_snapshot(root.path(), cache_path.clone());
1646
1647        let controls_off = stale(controls_config(CachePolicy::Auto, cache_path, false));
1648        let query = Query {
1649            views: vec![
1650                ViewSpec::Summary,
1651                ViewSpec::Tree,
1652                ViewSpec::Families,
1653                ViewSpec::Types,
1654                ViewSpec::Extensions,
1655                ViewSpec::Languages,
1656                ViewSpec::Largest,
1657                ViewSpec::Recent,
1658                ViewSpec::Files,
1659            ],
1660            ..Query::default()
1661        };
1662        let (projected, pending, performance) =
1663            prepared(root.path(), &controls_off, &query).expect("projected report");
1664        pending.join().expect("no cache-only save");
1665
1666        let cold = OpenFixture {
1667            policy: CachePolicy::Off,
1668            stale_ok: false,
1669            cache_path: None,
1670            ..controls_off
1671        };
1672        let (mut expected, pending, _) =
1673            prepared(root.path(), &cold, &query).expect("controls-off cold report");
1674        pending.join().expect("no cold save");
1675        expected.provenance = projected.provenance.clone();
1676
1677        assert_eq!(performance.source, ReportSource::CacheOnly);
1678        assert_eq!(projected.scope, cold.scan.scope());
1679        assert_eq!(projected.ignore_rules, crate::control::ControlCoverage::NotObserved);
1680        assert_eq!(
1681            crate::report_format::render(&projected, crate::report_format::Format::Json, false,)
1682                .expect("compatible report format"),
1683            crate::report_format::render(&expected, crate::report_format::Format::Json, false,)
1684                .expect("compatible report format"),
1685        );
1686    }
1687
1688    #[test]
1689    fn controls_on_snapshot_projects_to_controls_off_auto_report() {
1690        let root = tempfile::tempdir().expect("tempdir");
1691        let cache = tempfile::tempdir().expect("cache dir");
1692        let cache_path = cache.path().join("cache.fdu");
1693        seed_controls_snapshot(root.path(), cache_path.clone());
1694
1695        let controls_off = OpenFixture {
1696            analysis: crate::content::AnalysisRequest {
1697                profile: crate::content::AnalysisSet::NONE.with_lines(),
1698                ..Default::default()
1699            },
1700            ..controls_config(CachePolicy::Auto, cache_path, false)
1701        };
1702        let (report, pending, performance) = prepared(root.path(), &controls_off, &summary_query())
1703            .expect("controls-off warm projection");
1704        pending.join().expect("save content only");
1705
1706        assert_eq!(report.provenance.source, ReportSource::WarmRevalidate);
1707        assert_eq!(report.scope, controls_off.scan.scope());
1708        assert_eq!(performance.source, ReportSource::WarmRevalidate);
1709    }
1710
1711    /// Control sources past both limits, so no scan can observe them without saying so.
1712    ///
1713    /// The root rule is longer than the line limit and the nested source is past the
1714    /// table budget. An observing scan refuses both and records it in its control coverage;
1715    /// a report whose scope observes no control state read neither.
1716    fn write_unobservable_controls(root: &Path) {
1717        let mut rule = vec![b'a'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
1718        rule.push(b'\n');
1719        fs::write(root.join(".gitignore"), rule).expect("oversized rule");
1720        fs::create_dir(root.join("vendored")).expect("nested directory");
1721        fs::write(
1722            root.join("vendored/.gitignore"),
1723            b"x\n".repeat(crate::control::DEFAULT_CONTROL_BUDGET / 2),
1724        )
1725        .expect("oversized source");
1726    }
1727
1728    #[test]
1729    fn a_one_shot_report_observes_control_state_as_its_caller_configures() {
1730        // A default report reads every `.gitignore`, and a file past a bound is refused and
1731        // named without ending the report or its snapshot. A report that turns observation
1732        // off reads neither file, and says so in its report and in any snapshot it writes;
1733        // `on` makes each one write.
1734        let root = tempfile::tempdir().expect("tempdir");
1735        fs::write(root.path().join("file.txt"), b"contents").expect("file");
1736        write_unobservable_controls(root.path());
1737        let mut tree_query = summary_query();
1738        tree_query.views = vec![ViewSpec::Tree];
1739
1740        for read_controls in [true, false] {
1741            let cache = tempfile::tempdir().expect("cache dir");
1742            let cache_path = cache.path().join("cache.fdu");
1743            let caller = controls_config(CachePolicy::On, cache_path.clone(), read_controls);
1744            for query in [summary_query(), tree_query.clone()] {
1745                let (report, pending, _) = prepared(root.path(), &caller, &query)
1746                    .expect("a refused control file ends nothing");
1747                pending.join().expect("save");
1748                assert!(
1749                    report.status.complete,
1750                    "a refusal is not a partial: {:?}",
1751                    report.status.errors
1752                );
1753                assert_eq!(report.scope, caller.scan.scope());
1754                match &report.ignore_rules {
1755                    crate::control::ControlCoverage::Observed(coverage) => {
1756                        assert!(read_controls, "observed only when asked");
1757                        assert_eq!(coverage.refused, 2, "{coverage:?}");
1758                        assert_eq!(report.notes.len(), 2, "{:?}", report.notes);
1759                        assert!(
1760                            report.notes[0].contains("2 ignore files not applied"),
1761                            "{:?}",
1762                            report.notes
1763                        );
1764                        assert_eq!(
1765                            report.notes[1],
1766                            "note: gitignored subtotals are unavailable where governing rules could not be verified"
1767                        );
1768                    }
1769                    crate::control::ControlCoverage::NotObserved => {
1770                        assert!(!read_controls, "unobserved only when turned off");
1771                        assert!(report.notes.is_empty(), "{:?}", report.notes);
1772                    }
1773                }
1774            }
1775
1776            let saved = crate::snapshot::load(&cache_path)
1777                .expect("load the snapshot")
1778                .expect("the index tier persisted");
1779            assert_eq!(saved.scope(), caller.scan.scope());
1780            assert_eq!(saved.controls().is_ok(), read_controls);
1781        }
1782    }
1783
1784    /// Which failure a run names, and what kind of failure it is, must not depend on how it
1785    /// was delivered.
1786    ///
1787    /// A scope this build cannot honour is refused by every policy, and by a stale answer,
1788    /// which never scans: under `--stale-ok` the scan that would have refused it never runs,
1789    /// so the run used to report a snapshot miss instead -- the same request naming two
1790    /// different failures depending on its delivery, which the path-independence registry
1791    /// records as `refusal-order` for `--one-filesystem` on Windows. `follow_symlinks` is
1792    /// the same rule on every platform, so this test runs where the Windows case cannot.
1793    ///
1794    /// The refusal is the request model's typed one, not an engine error the surfaces then
1795    /// classify differently: reporting it as an engine error made the command line exit 1
1796    /// where Python raised `ValueError`, one request with two kinds of outcome.
1797    #[test]
1798    fn a_scope_this_build_cannot_honour_is_refused_before_any_snapshot_is_read() {
1799        let root = tempfile::tempdir().expect("tempdir");
1800        fs::write(root.path().join("file.txt"), b"contents").expect("file");
1801        let cache = tempfile::tempdir().expect("cache dir");
1802        let cache_path = cache.path().join("cache.fdu");
1803
1804        // A usable snapshot exists, so a stale read of a scope this build supports answers
1805        // from it.
1806        let warm = config(CachePolicy::On, Some(cache_path.clone()));
1807        let (_, pending, _) = prepared(root.path(), &warm, &summary_query()).expect("warm");
1808        pending.join().expect("save");
1809        let (_, pending, _) = prepared(
1810            root.path(),
1811            &stale(config(CachePolicy::Auto, Some(cache_path.clone()))),
1812            &summary_query(),
1813        )
1814        .expect("the snapshot answers a supported scope");
1815        pending.join().expect("no save");
1816
1817        let unsupported = ScanConfig { follow_symlinks: true, ..ScanConfig::default() };
1818        for (policy, stale_ok) in [
1819            (CachePolicy::Auto, true),
1820            (CachePolicy::Off, false),
1821            (CachePolicy::Auto, false),
1822            (CachePolicy::On, false),
1823        ] {
1824            let asked = OpenFixture {
1825                scan: unsupported.clone(),
1826                stale_ok,
1827                ..config(policy, Some(cache_path.clone()))
1828            };
1829            let refused = prepared(root.path(), &asked, &summary_query())
1830                .expect_err("a scope this build cannot honour has no answer at any policy");
1831            assert!(
1832                matches!(
1833                    refused,
1834                    Error::InvalidRequest(crate::query::RequestError::ScopeUnsupported {
1835                        axis: crate::query::ScopeAxis::FollowSymlinks,
1836                        ..
1837                    })
1838                ),
1839                "{policy:?} must refuse the request rather than fail the operation: {refused}"
1840            );
1841            assert_eq!(
1842                refused.to_string(),
1843                "unsupported scan configuration: follow_symlinks requires cycle, root-boundary, \
1844                 and filesystem-boundary semantics",
1845                "{policy:?} must name the scope it cannot honour"
1846            );
1847        }
1848    }
1849
1850    #[test]
1851    fn a_report_that_reads_no_gitignore_refuses_to_select_by_ignored_state() {
1852        // No entry of a scan that read no rule can be shown to be ignored or not, so the
1853        // request is refused rather than answered with every entry or none.
1854        let root = tempfile::tempdir().expect("tempdir");
1855        fs::write(root.path().join(".gitignore"), b"*.log\n").expect("control file");
1856        fs::write(root.path().join("debug.log"), b"ignored").expect("ignored file");
1857        let mut only = summary_query();
1858        only.selection.ignored = IgnoredEntries::Only;
1859
1860        // Refused by the request model before anything is scanned: the compact summary
1861        // tier this request would take reaches no reader, so a check made there would not
1862        // cover this route at all.
1863        assert!(matches!(
1864            prepared(root.path(), &blind(CachePolicy::Off, None), &only),
1865            Err(Error::InvalidRequest(crate::query::RequestError::IgnoredWithoutObservation(
1866                IgnoredEntries::Only
1867            )))
1868        ));
1869
1870        let (report, pending, _) =
1871            prepared(root.path(), &config(CachePolicy::Off, None), &only).expect("observed");
1872        pending.join().expect("no save");
1873        let Section::Summary(row) = report.sections[0] else { panic!("a summary") };
1874        assert_eq!((row.files, row.bytes), (1, 7), "only the ignored file is selected");
1875    }
1876
1877    #[test]
1878    fn an_on_reports_snapshot_serves_either_cache_only_report_but_not_the_reverse() {
1879        // Every surface reaches this planner observing control state by default, so a
1880        // snapshot a default-scope report wrote under `on` serves the next one. A cache-only report that
1881        // turns observation off also answers from it, reading only the all-entry facts. An
1882        // opted-out snapshot holds no classification, so it cannot serve a default report,
1883        // and the refusal says why and what recovers.
1884        let root = tempfile::tempdir().expect("tempdir");
1885        fs::write(root.path().join("file.txt"), b"contents").expect("file");
1886        let mut tree_query = summary_query();
1887        tree_query.views = vec![ViewSpec::Tree];
1888
1889        for (writer, reader) in [(true, true), (true, false), (false, false), (false, true)] {
1890            let cache = tempfile::tempdir().expect("cache dir");
1891            let cache_path = cache.path().join("cache.fdu");
1892            let write = controls_config(CachePolicy::On, cache_path.clone(), writer);
1893            let (_, pending, _) =
1894                prepared(root.path(), &write, &tree_query).expect("writing report");
1895            pending.join().expect("save");
1896
1897            let read = stale(controls_config(CachePolicy::Auto, cache_path, reader));
1898            match prepared(root.path(), &read, &tree_query) {
1899                Ok((report, pending, _)) => {
1900                    pending.join().expect("no save");
1901                    assert!(writer || !reader, "writer {writer} served reader {reader}");
1902                    assert_eq!(report.provenance.source, ReportSource::CacheOnly);
1903                    assert_eq!(
1904                        matches!(report.ignore_rules, crate::control::ControlCoverage::Observed(_)),
1905                        reader,
1906                        "a report describes the scope it asked for"
1907                    );
1908                }
1909                Err(Error::Snapshot(message)) => {
1910                    assert!(!writer && reader, "writer {writer}, reader {reader}: {message}");
1911                    assert!(message.contains(".gitignore state"), "names the cause: {message}");
1912                    assert!(message.contains("verified answer"), "names the remedy: {message}");
1913                }
1914                Err(other) => panic!("writer {writer}, reader {reader}: {other}"),
1915            }
1916        }
1917    }
1918
1919    #[test]
1920    fn a_cache_only_open_answers_from_an_on_reports_snapshot() {
1921        // A default-scope report and a default `open` share one scope, so the one delivery
1922        // that forbids a scan answers the `open` from the snapshot the report left under
1923        // `on`, with the classification the report observed.
1924        let root = tempfile::tempdir().expect("tempdir");
1925        fs::write(root.path().join(".gitignore"), b"*.log\n").expect("control file");
1926        fs::write(root.path().join("debug.log"), b"ignored").expect("ignored file");
1927        let cache = tempfile::tempdir().expect("cache dir");
1928        let cache_path = cache.path().join("cache.fdu");
1929        let mut tree_query = summary_query();
1930        tree_query.views = vec![ViewSpec::Tree];
1931
1932        let on = config(CachePolicy::On, Some(cache_path.clone()));
1933        let (_, pending, _) = prepared(root.path(), &on, &tree_query).expect("report");
1934        pending.join().expect("save");
1935
1936        let only = stale(config(CachePolicy::Auto, Some(cache_path)));
1937        let (index, report) = crate::open_fixture(root.path(), &only).expect("the shared snapshot");
1938        assert_eq!(report.path_taken, OpenPath::CacheOnly);
1939        assert_eq!(index.is_ignored(Path::new("debug.log")).ok(), Some(Some(true)));
1940    }
1941
1942    #[test]
1943    fn an_open_that_opts_out_of_control_state_shares_an_opted_out_reports_snapshot() {
1944        // A report and an `open` that both turn observation off write and want one scope,
1945        // so that open answers from the snapshot the report left under `on` without
1946        // touching the tree, as the one delivery that forbids a scan, and says it cannot
1947        // classify ignored entries.
1948        let root = tempfile::tempdir().expect("tempdir");
1949        fs::write(root.path().join("file.txt"), b"contents").expect("file");
1950        let cache = tempfile::tempdir().expect("cache dir");
1951        let cache_path = cache.path().join("cache.fdu");
1952        let mut tree_query = summary_query();
1953        tree_query.views = vec![ViewSpec::Tree];
1954
1955        let on = blind(CachePolicy::On, Some(cache_path.clone()));
1956        let (_, pending, _) = prepared(root.path(), &on, &tree_query).expect("report");
1957        pending.join().expect("save");
1958        assert!(cache_path.exists(), "the report left a snapshot");
1959
1960        let only = stale(blind(CachePolicy::Auto, Some(cache_path)));
1961        let (index, report) = crate::open_fixture(root.path(), &only).expect("the shared snapshot");
1962        assert_eq!(report.path_taken, OpenPath::CacheOnly);
1963        assert!(matches!(
1964            index.is_ignored(Path::new("file.txt")),
1965            Err(Error::ControlStateNotObserved)
1966        ));
1967    }
1968
1969    #[test]
1970    fn compact_summary_matches_the_indexed_summary_exactly() {
1971        let root = tempfile::tempdir().expect("tempdir");
1972        fs::create_dir(root.path().join("src")).expect("directory");
1973        fs::write(root.path().join("src/lib.rs"), b"library").expect("file");
1974        fs::write(root.path().join("README.md"), b"read me").expect("file");
1975        #[cfg(unix)]
1976        std::os::unix::fs::symlink("README.md", root.path().join("readme-link")).expect("symlink");
1977
1978        let query = summary_query();
1979        // Two workers so the compact fold exercises StreamingEmission recycle even on
1980        // a one-vCPU runner (`threads: None` would take the serial walker there).
1981        let off = OpenFixture {
1982            scan: ScanConfig { read_controls: false, threads: Some(2), ..ScanConfig::default() },
1983            ..blind(CachePolicy::Off, None)
1984        };
1985        let (compact, pending, performance) =
1986            prepared(root.path(), &off, &query).expect("compact report");
1987        pending.join().expect("no pending compact save");
1988        assert_eq!(performance.walked_files, 2);
1989        assert_eq!(performance.walked_bytes, 14);
1990
1991        // This report turns control observation off, so the index it must match exactly is
1992        // opened under that scope too.
1993        let (index, _open_report) = crate::open_fixture(root.path(), &off).expect("indexed scan");
1994        let indexed = report(
1995            &index,
1996            &crate::test_support::read_of(&index, query.clone()),
1997            compact.provenance.generated_at,
1998        )
1999        .expect("report");
2000
2001        let Section::Summary(compact_row) = compact.sections[0] else {
2002            panic!("compact plan did not return a summary")
2003        };
2004        let Section::Summary(indexed_row) = indexed.sections[0] else {
2005            panic!("indexed plan did not return a summary")
2006        };
2007        assert_eq!(compact_row.files, indexed_row.files);
2008        assert_eq!(compact_row.dirs, indexed_row.dirs);
2009        assert_eq!(compact_row.bytes, indexed_row.bytes);
2010        assert_eq!(compact_row.allocated, indexed_row.allocated);
2011        assert_eq!(compact_row.newest_mtime_ns, indexed_row.newest_mtime_ns);
2012        assert_eq!(compact.root, indexed.root);
2013        assert_eq!(compact.scope, indexed.scope);
2014        assert_eq!(compact.status.complete, indexed.status.complete);
2015        assert_eq!(compact.provenance.freshness, indexed.provenance.freshness);
2016    }
2017
2018    /// One tree for the transient-versus-indexed differential, the scope it is summarized
2019    /// under, and what the index must say of it, so a case that stopped exercising what it
2020    /// names fails instead of agreeing vacuously.
2021    struct ControlCase {
2022        name: &'static str,
2023        root: tempfile::TempDir,
2024        scan: ScanConfig,
2025        /// Whether the row carries an ignored share, which a refused or unreadable control
2026        /// file withholds.
2027        share: bool,
2028        /// Control files refused.
2029        refused: u64,
2030        /// Which files a budget refuses depends on the order controls arrive in, so the two
2031        /// routes are compared only where that order is fixed: with one worker.
2032        order_dependent: bool,
2033        /// Walk errors the case induces.
2034        errors: bool,
2035        /// How control lookups under the case's root resolve a case variant of the name.
2036        lookups: crate::test_support::CaseLookups,
2037        /// For a tree whose rules sit in a case variant of the name: whether they govern,
2038        /// which is whether a lookup of `.gitignore` resolves to the variant.
2039        variant_governs: Option<bool>,
2040        /// A file the case made unreadable, readable again when the case is dropped so its
2041        /// tree can be removed even after a failed assertion. Only Unix can make one.
2042        #[cfg(unix)]
2043        denied: Option<PathBuf>,
2044    }
2045
2046    impl Drop for ControlCase {
2047        fn drop(&mut self) {
2048            #[cfg(unix)]
2049            if let Some(path) = &self.denied {
2050                use std::os::unix::fs::PermissionsExt;
2051                let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
2052            }
2053        }
2054    }
2055
2056    fn put(root: &Path, path: &str, contents: &[u8]) {
2057        let path = root.join(path);
2058        fs::create_dir_all(path.parent().expect("a parent")).expect("parent directories");
2059        fs::write(path, contents).expect("fixture file");
2060    }
2061
2062    /// Negation, nested files, directory-only and anchored rules, rules below an ignored
2063    /// directory that try to re-include, a re-included directory, a control file that
2064    /// ignores itself, and a listing long enough to split across small batches.
2065    fn rules_tree() -> tempfile::TempDir {
2066        let root = tempfile::tempdir().expect("tempdir");
2067        let root_path = root.path();
2068        put(
2069            root_path,
2070            ".gitignore",
2071            b"*.log\n!keep.log\n/anchored.txt\ncache/\nnode_modules/\nvendor/*\n!vendor/keep/\n",
2072        );
2073        put(root_path, "a.log", b"alog");
2074        put(root_path, "keep.log", b"keeplog");
2075        put(root_path, "anchored.txt", b"anchored");
2076        put(root_path, "cache", b"a file named like a directory rule");
2077        put(root_path, "README.md", b"readme!");
2078        put(root_path, "src/anchored.txt", b"not anchored here");
2079        put(root_path, "src/x.log", b"xlog-");
2080        put(root_path, "src/keep.log", b"kept everywhere");
2081        put(root_path, "src/main.rs", b"fn main() {}");
2082        put(root_path, "sub/.gitignore", b"!*.log\n*.tmp\n");
2083        put(root_path, "sub/y.log", b"re-included");
2084        put(root_path, "sub/z.tmp", b"tmp");
2085        put(root_path, "sub/cache/data.bin", b"cached bytes");
2086        put(root_path, "sub/deep/.gitignore", b"*\n!.gitignore\n");
2087        put(root_path, "sub/deep/f.txt", b"deep");
2088        put(root_path, "sub/deep/inner/g.txt", b"deeper");
2089        put(root_path, "node_modules/pkg/.gitignore", b"!*\n");
2090        put(root_path, "node_modules/pkg/index.js", b"module.exports = 1;");
2091        put(root_path, "node_modules/pkg/lib/a.js", b"a");
2092        put(root_path, "vendor/a.c", b"int a;");
2093        put(root_path, "vendor/keep/k.c", b"int k;");
2094        put(root_path, "vendor/drop/d.c", b"int d;");
2095        put(root_path, "selfish/.gitignore", b".gitignore\n*.bak\n");
2096        put(root_path, "selfish/x.bak", b"backup");
2097        put(root_path, "selfish/y.txt", b"kept");
2098        put(root_path, "many/.gitignore", b"*[02468].dat\n");
2099        for file in 0..40 {
2100            put(root_path, &format!("many/f{file:02}.dat"), &vec![b'.'; file + 1]);
2101        }
2102        root
2103    }
2104
2105    /// The control case whose only control-like file is `.GITIGNORE`.
2106    const CASE_VARIANT: &str = "a case-variant control name";
2107
2108    /// The control case whose directory lists `.gitignore` beside `.GITIGNORE`.
2109    const CASE_BOTH: &str = "both spellings of the control name";
2110
2111    fn control_cases() -> Vec<ControlCase> {
2112        let case = |name, root, scan, share, refused| ControlCase {
2113            name,
2114            root,
2115            scan,
2116            share,
2117            refused,
2118            order_dependent: false,
2119            errors: false,
2120            lookups: crate::test_support::CaseLookups::Host,
2121            variant_governs: None,
2122            #[cfg(unix)]
2123            denied: None,
2124        };
2125        let mut cases = vec![
2126            case("rules", rules_tree(), ScanConfig::default(), true, 0),
2127            case(
2128                "rules with hidden entries pruned",
2129                rules_tree(),
2130                ScanConfig {
2131                    hidden: Some(std::sync::Arc::new(crate::HiddenPolicy::prune_hidden(Vec::<
2132                        std::ffi::OsString,
2133                    >::new(
2134                    )))),
2135                    ..ScanConfig::default()
2136                },
2137                true,
2138                0,
2139            ),
2140            case(
2141                "rules under a depth bound",
2142                rules_tree(),
2143                ScanConfig { max_depth: Some(1), ..ScanConfig::default() },
2144                true,
2145                0,
2146            ),
2147        ];
2148
2149        let empty = tempfile::tempdir().expect("tempdir");
2150        put(empty.path(), "only.txt", b"no rules anywhere");
2151        cases.push(case("no control file", empty, ScanConfig::default(), true, 0));
2152
2153        // A line over the limit refuses its whole file, whatever order it arrives in.
2154        let long = tempfile::tempdir().expect("tempdir");
2155        put(long.path(), ".gitignore", b"*.log\n");
2156        put(long.path(), "x.log", b"ignored");
2157        let mut line = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
2158        line.push(b'\n');
2159        put(long.path(), "long/.gitignore", &line);
2160        put(long.path(), "long/kept.txt", b"kept");
2161        put(long.path(), "long/y.log", b"unknown");
2162        cases.push(case("line limit", long, ScanConfig::default(), false, 1));
2163
2164        // Each nested file alone exceeds what the root leaves of the budget, so all seventy
2165        // are refused in any order: more than a note names and more than a report retains.
2166        let over = tempfile::tempdir().expect("tempdir");
2167        put(over.path(), ".gitignore", b"*.log\n");
2168        let mut oversized = vec![b'x'; 200];
2169        oversized.push(b'\n');
2170        for directory in 0..70 {
2171            put(over.path(), &format!("d{directory:02}/.gitignore"), &oversized);
2172            put(over.path(), &format!("d{directory:02}/f.log"), b"log");
2173        }
2174        let limits = crate::control::ControlLimits { budget: Some(256), ..Default::default() };
2175        cases.push(case(
2176            "every nested file over the budget",
2177            over,
2178            ScanConfig { control_limits: limits, ..ScanConfig::default() },
2179            false,
2180            70,
2181        ));
2182
2183        // Any two of four fit the budget and no third does: which two is arrival order.
2184        let competing = tempfile::tempdir().expect("tempdir");
2185        for (position, directory) in ["a", "b", "c", "d"].into_iter().enumerate() {
2186            let mut rules = format!("r{position}").into_bytes();
2187            rules.extend(std::iter::repeat_n(b'y', 100));
2188            rules.push(b'\n');
2189            put(competing.path(), &format!("{directory}/.gitignore"), &rules);
2190            put(competing.path(), &format!("{directory}/file.txt"), b"file");
2191        }
2192        let limits = crate::control::ControlLimits { budget: Some(1000), ..Default::default() };
2193        let mut competing = case(
2194            "competing for the budget",
2195            competing,
2196            ScanConfig { control_limits: limits, ..ScanConfig::default() },
2197            false,
2198            2,
2199        );
2200        competing.order_dependent = true;
2201        cases.push(competing);
2202
2203        // A case variant of the control name governs exactly where a lookup of `.gitignore`
2204        // resolves to it, as the open git makes does (fdu-0w1b): on a case-insensitive
2205        // volume, and through folded lookups on a case-sensitive host. Enough entries fill
2206        // a default batch before the listing reaches `.GITIGNORE` (enumeration order
2207        // permitting), so the transient fold's probe must find what the listed variant's
2208        // read and the index find. Hidden pruning drops the variant's row, not its rules.
2209        let probe = tempfile::tempdir().expect("tempdir");
2210        for (lookups, governs) in crate::test_support::CaseLookups::on_this_host(probe.path()) {
2211            for hidden in [None, Some(Vec::<std::ffi::OsString>::new())] {
2212                let variant = tempfile::tempdir().expect("tempdir");
2213                put(variant.path(), ".GITIGNORE", b"*.log\n");
2214                for file in 0..1_200 {
2215                    put(variant.path(), &format!("f{file:04}.log"), b"log");
2216                }
2217                put(variant.path(), "kept.txt", b"kept");
2218                let scan = ScanConfig {
2219                    hidden: hidden
2220                        .map(|allow| std::sync::Arc::new(crate::HiddenPolicy::prune_hidden(allow))),
2221                    ..ScanConfig::default()
2222                };
2223                let mut variant = case(CASE_VARIANT, variant, scan, true, 0);
2224                variant.lookups = lookups;
2225                variant.variant_governs = Some(governs);
2226                cases.push(variant);
2227            }
2228        }
2229
2230        // A case-sensitive directory can list both spellings, and only the exact name
2231        // governs there, whatever the lookups; a case-insensitive one cannot hold both, and
2232        // writing the second spelling there rewrites the first file.
2233        for lookups in
2234            [crate::test_support::CaseLookups::Host, crate::test_support::CaseLookups::Folded]
2235        {
2236            let both = tempfile::tempdir().expect("tempdir");
2237            put(both.path(), ".gitignore", b"*.log\n");
2238            put(both.path(), ".GITIGNORE", b"*.tmp\n");
2239            if fs::read(both.path().join(".gitignore")).expect("read") != b"*.log\n" {
2240                eprintln!("skipped {CASE_BOTH:?}: the temporary directory is case-insensitive");
2241                break;
2242            }
2243            for file in 0..1_200 {
2244                put(both.path(), &format!("f{file:04}.tmp"), b"tmp");
2245            }
2246            put(both.path(), "x.log", b"log");
2247            let mut both = case(CASE_BOTH, both, ScanConfig::default(), true, 0);
2248            both.lookups = lookups;
2249            cases.push(both);
2250        }
2251
2252        #[cfg(unix)]
2253        {
2254            use std::os::unix::fs::PermissionsExt;
2255
2256            // A control file that is a directory or a symlink applies no rules.
2257            let shapes = tempfile::tempdir().expect("tempdir");
2258            put(shapes.path(), ".gitignore", b"*.tmp\n");
2259            put(shapes.path(), "weird/.gitignore/inner.tmp", b"inner");
2260            put(shapes.path(), "weird/kept.txt", b"kept");
2261            put(shapes.path(), "rules.txt", b"*.txt\n");
2262            fs::create_dir(shapes.path().join("linked")).expect("directory");
2263            std::os::unix::fs::symlink("../rules.txt", shapes.path().join("linked/.gitignore"))
2264                .expect("symlink");
2265            put(shapes.path(), "linked/still.txt", b"still counted");
2266            cases.push(case(
2267                "control files that are not files",
2268                shapes,
2269                ScanConfig::default(),
2270                true,
2271                0,
2272            ));
2273
2274            if crate::test_support::require_permission_bits() {
2275                let unreadable = tempfile::tempdir().expect("tempdir");
2276                put(unreadable.path(), ".gitignore", b"*.log\n");
2277                put(unreadable.path(), "x.log", b"ignored");
2278                put(unreadable.path(), "sub/.gitignore", b"!*.log\n");
2279                put(unreadable.path(), "sub/y.log", b"unknown");
2280                let control = unreadable.path().join("sub/.gitignore");
2281                let mut denied =
2282                    case("unreadable control file", unreadable, ScanConfig::default(), false, 0);
2283                fs::set_permissions(&control, fs::Permissions::from_mode(0o000))
2284                    .expect("deny the control file");
2285                denied.denied = Some(control);
2286                denied.errors = true;
2287                cases.push(denied);
2288            }
2289        }
2290        cases
2291    }
2292
2293    /// The default summary of `root` under `scan`, from the transient tier and from the
2294    /// index, with the transient report's provenance, which describes the delivery rather
2295    /// than the tree, set to the index's once the parts both routes share agree.
2296    fn transient_and_indexed(root: &Path, scan: &ScanConfig, label: &str) -> (Report, Report) {
2297        let query = summary_query();
2298        let transient = OpenFixture { scan: scan.clone(), ..config(CachePolicy::Off, None) };
2299        assert_eq!(planned(&transient, &query).retained, RetainedState::Summary, "{label}");
2300        let (mut compact, pending, compact_performance) =
2301            prepared(root, &transient, &query).expect("transient report");
2302        pending.join().expect("the transient tier saves nothing");
2303
2304        // `on` with a snapshot path is a delivery about the snapshot, so the same request
2305        // takes the index: the route every default summary took before fdu-1ovb.
2306        let cache = tempfile::tempdir().expect("cache dir");
2307        let indexed = OpenFixture {
2308            scan: scan.clone(),
2309            ..config(CachePolicy::On, Some(cache.path().join("snapshot.fdu")))
2310        };
2311        assert_eq!(planned(&indexed, &query).retained, RetainedState::FullIndex, "{label}");
2312        let (indexed, pending, indexed_performance) =
2313            prepared(root, &indexed, &query).expect("indexed report");
2314        pending.join().expect("save");
2315
2316        assert_eq!(
2317            (
2318                compact_performance.walked_files,
2319                compact_performance.walked_bytes,
2320                compact_performance.walked_allocated,
2321                compact_performance.source,
2322            ),
2323            (
2324                indexed_performance.walked_files,
2325                indexed_performance.walked_bytes,
2326                indexed_performance.walked_allocated,
2327                indexed_performance.source,
2328            ),
2329            "{label}: walked totals"
2330        );
2331        assert_eq!(compact.provenance.source, indexed.provenance.source, "{label}");
2332        assert_eq!(compact.provenance.freshness, indexed.provenance.freshness, "{label}");
2333        assert_eq!(
2334            (compact.provenance.tiers.entries.source, compact.provenance.tiers.entries.freshness),
2335            (indexed.provenance.tiers.entries.source, indexed.provenance.tiers.entries.freshness),
2336            "{label}"
2337        );
2338        compact.provenance = indexed.provenance.clone();
2339        (compact, indexed)
2340    }
2341
2342    /// The transient summary is the indexed summary, whole, for every control case the
2343    /// index classifies: negations, nested and self-ignoring control files, rules below an
2344    /// ignored directory, control files that are not files, refusals by the line limit
2345    /// and by the budget, an unreadable control file, a case-variant control name where
2346    /// the volume is case-insensitive, and the notes and coverage each produces. Across
2347    /// worker counts, batch sizes small enough to split every listing, and both traversal
2348    /// orders (fdu-1ovb).
2349    #[test]
2350    fn compact_summary_equals_the_indexed_summary_under_every_control_case() {
2351        use crate::report_format::{Format, render};
2352
2353        let default_batch = ScanConfig::default().batch_size;
2354        for case in control_cases() {
2355            let _lookups = case.lookups.install(case.root.path());
2356            let mut compared = 0;
2357            for threads in [Some(1), Some(2), Some(4), None] {
2358                if case.order_dependent && threads != Some(1) {
2359                    continue;
2360                }
2361                for batch_size in [default_batch, 1, 3] {
2362                    for order in [crate::ScanOrder::BreadthFirst, crate::ScanOrder::DepthFirst] {
2363                        let scan = ScanConfig { batch_size, threads, order, ..case.scan.clone() };
2364                        let label = format!(
2365                            "{} ({:?} lookups, {threads:?} workers, batch {batch_size}, {order:?})",
2366                            case.name, case.lookups
2367                        );
2368                        let (compact, indexed) =
2369                            transient_and_indexed(case.root.path(), &scan, &label);
2370
2371                        assert_eq!(format!("{compact:#?}"), format!("{indexed:#?}"), "{label}");
2372                        for format in [Format::Text, Format::Json, Format::Yaml] {
2373                            assert_eq!(
2374                                render(&compact, format, false).expect("render"),
2375                                render(&indexed, format, false).expect("render"),
2376                                "{label}: {format:?}"
2377                            );
2378                        }
2379
2380                        // What each case exists to exercise.
2381                        let Section::Summary(row) = indexed.sections[0] else {
2382                            panic!("{label}: a summary")
2383                        };
2384                        let crate::control::ControlCoverage::Observed(coverage) =
2385                            &indexed.ignore_rules
2386                        else {
2387                            panic!("{label}: the default scope observes .gitignore")
2388                        };
2389                        assert_eq!(coverage.refused, case.refused, "{label}");
2390                        assert_eq!(row.ignored.is_some(), case.share, "{label}");
2391                        assert_eq!(!indexed.status.errors.is_empty(), case.errors, "{label}");
2392                        if case.share && case.name.starts_with("rules") {
2393                            let ignored = row.ignored.expect("a share");
2394                            assert!(
2395                                ignored.files > 0 && ignored.files < row.files && ignored.dirs > 0,
2396                                "{label}: {ignored:?} of {row:?}"
2397                            );
2398                        }
2399                        if let Some(governs) = case.variant_governs {
2400                            assert_eq!(coverage.applied, u64::from(governs), "{label}");
2401                            assert_eq!(
2402                                row.ignored.map(|ignored| ignored.files),
2403                                Some(if governs { 1_200 } else { 0 }),
2404                                "{label}"
2405                            );
2406                        }
2407                        if case.name == CASE_BOTH {
2408                            assert_eq!(coverage.applied, 1, "{label}: the exact name alone");
2409                            assert_eq!(
2410                                row.ignored.map(|ignored| ignored.files),
2411                                Some(1),
2412                                "{label}: only `x.log` is ignored"
2413                            );
2414                        }
2415                        if !case.share {
2416                            assert!(
2417                                indexed
2418                                    .notes
2419                                    .iter()
2420                                    .any(|note| note.contains("could not be verified")),
2421                                "{label}: {:?}",
2422                                indexed.notes
2423                            );
2424                        }
2425                        compared += 1;
2426                    }
2427                }
2428            }
2429            assert!(compared > 0, "{} was compared", case.name);
2430        }
2431    }
2432
2433    #[test]
2434    fn compact_summary_never_creates_the_configured_snapshot() {
2435        let root = tempfile::tempdir().expect("tempdir");
2436        fs::write(root.path().join("payload"), b"payload").expect("file");
2437        let cache = root.path().join("must-not-exist.fdu");
2438
2439        let (report, pending, _) =
2440            prepared(root.path(), &blind(CachePolicy::Off, Some(cache.clone())), &summary_query())
2441                .expect("compact report");
2442        pending.join().expect("no pending compact save");
2443
2444        assert!(report.status.complete);
2445        assert!(!cache.exists());
2446    }
2447
2448    #[test]
2449    fn full_index_report_exposes_scan_diagnostics_when_requested() {
2450        let root = tempfile::tempdir().expect("tempdir");
2451        fs::create_dir(root.path().join("nested")).expect("directory");
2452        fs::write(root.path().join("nested/file.txt"), b"trace me").expect("file");
2453        let query = Query { views: vec![ViewSpec::Tree], ..Query::default() };
2454
2455        let (report, pending, performance, diagnostics) =
2456            prepared_with_diagnostics(root.path(), &config(CachePolicy::Off, None), &query)
2457                .expect("full-index report");
2458        pending.join().expect("no pending save");
2459
2460        assert!(report.status.complete);
2461        assert_eq!(performance.walked_files, 1);
2462        let diagnostics = diagnostics.expect("full-index scan diagnostics");
2463        assert_eq!(diagnostics.schema, crate::scan::SCAN_DIAGNOSTICS_SCHEMA);
2464        assert_eq!(diagnostics.worker_policy.ready_directories_at_finish, 0);
2465        assert_eq!(diagnostics.worker_policy.in_flight_directories_at_finish, 0);
2466    }
2467
2468    /// A tree of `dirs` directories under the root, each holding `files` files of
2469    /// distinct sizes, with its file count and byte total.
2470    fn wide_tree(dirs: usize, files: usize) -> (tempfile::TempDir, u64, u64) {
2471        let root = tempfile::tempdir().expect("tempdir");
2472        let mut bytes = 0;
2473        for directory in 0..dirs {
2474            let path = root.path().join(format!("d{directory:03}"));
2475            fs::create_dir(&path).expect("directory");
2476            for file in 0..files {
2477                let size = directory * files + file + 1;
2478                fs::write(path.join(format!("f{file}.txt")), vec![b'.'; size]).expect("file");
2479                bytes += size as u64;
2480            }
2481        }
2482        (root, (dirs * files) as u64, bytes)
2483    }
2484
2485    /// [`prepared`], reporting through `progress`.
2486    fn prepared_with_progress(
2487        root: &Path,
2488        config: &OpenFixture,
2489        query: &Query,
2490        progress: &Progress,
2491    ) -> Result<(Report, PendingSave, PerformanceSummary)> {
2492        let (request, delivery) = split(root, config, query);
2493        prepare_report_with_progress(&request, &delivery, progress)
2494    }
2495
2496    fn analyzing(fixture: OpenFixture) -> OpenFixture {
2497        OpenFixture {
2498            analysis: crate::content::AnalysisRequest {
2499                profile: crate::content::AnalysisSet::LINES_ONLY,
2500                workers: 0,
2501            },
2502            ..fixture
2503        }
2504    }
2505
2506    /// The invariant the plan makes testable: when a route completes, the handle's
2507    /// files, bytes, and allocated bytes equal the walked totals the route's own
2508    /// performance summary reports, and its directories equal the directories the route
2509    /// read. The allocated figure is also the answer's own total, which is what lets a
2510    /// display put it beside the answer. Every one-shot route: the cold full index, the
2511    /// transient summary fold, a cold run with content analysis, and a warm revalidation
2512    /// of the snapshot that run left.
2513    #[test]
2514    fn progress_ends_at_the_walked_totals_of_every_one_shot_route() {
2515        use crate::ProgressPhase::{Scanning, Summarizing};
2516        let (root, files, bytes) = wide_tree(6, 4);
2517        let tree = Query { views: vec![ViewSpec::Tree], ..Query::default() };
2518
2519        let progress = Progress::new();
2520        let (_, pending, performance) =
2521            prepared_with_progress(root.path(), &config(CachePolicy::Off, None), &tree, &progress)
2522                .expect("cold full-index report");
2523        pending.join().expect("no save");
2524        let snapshot = progress.snapshot();
2525        assert_eq!(performance.source, ReportSource::ColdScan);
2526        assert_eq!((performance.walked_files, performance.walked_bytes), (files, bytes));
2527        assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "cold full index");
2528        assert_eq!(snapshot.allocated, performance.walked_allocated);
2529        assert!(snapshot.allocated > 0, "files with content occupy blocks");
2530        assert_eq!(snapshot.directories, 7, "the root and its six children");
2531        assert_eq!(
2532            (snapshot.phase, snapshot.analysis),
2533            (Summarizing, None),
2534            "the walk ended, the index was assembled, then the answer was built"
2535        );
2536
2537        let progress = Progress::new();
2538        let (report, pending, performance) = prepared_with_progress(
2539            root.path(),
2540            &blind(CachePolicy::Off, None),
2541            &summary_query(),
2542            &progress,
2543        )
2544        .expect("compact summary report");
2545        pending.join().expect("no save");
2546        let Section::Summary(row) = report.sections[0] else { panic!("summary section") };
2547        let snapshot = progress.snapshot();
2548        assert_eq!((performance.walked_files, performance.walked_bytes), (files, bytes));
2549        assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "summary fold");
2550        assert_eq!(snapshot.allocated, performance.walked_allocated);
2551        assert_eq!(snapshot.allocated, row.allocated, "the progress figure is the answer's");
2552        assert_eq!(snapshot.directories, row.dirs + 1, "the row's directories and the root");
2553        assert_eq!((snapshot.phase, snapshot.analysis), (Scanning, None));
2554
2555        // Outside the tree: a cache inside it is two more files for the warm walk.
2556        let cache_dir = tempfile::tempdir().expect("cache dir");
2557        let cache = cache_dir.path().join("snapshot.fdu");
2558        let progress = Progress::new();
2559        let (_, pending, performance) = prepared_with_progress(
2560            root.path(),
2561            &analyzing(config(CachePolicy::Auto, Some(cache.clone()))),
2562            &tree,
2563            &progress,
2564        )
2565        .expect("cold analyzed report");
2566        let snapshot = progress.snapshot();
2567        assert_eq!(snapshot.phase, Summarizing, "the answer is built while the save runs");
2568        pending.join().expect("save");
2569        assert_eq!(performance.source, ReportSource::ColdScan);
2570        assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "cold with analysis");
2571        assert_eq!(snapshot.allocated, performance.walked_allocated);
2572        assert_eq!(snapshot.directories, 7);
2573        assert_eq!(performance.fresh_files, files, "every file is a lines candidate");
2574        assert_eq!(snapshot.analysis, Some((files, files)));
2575
2576        let progress = Progress::new();
2577        let (_, pending, performance) = prepared_with_progress(
2578            root.path(),
2579            &analyzing(config(CachePolicy::Auto, Some(cache))),
2580            &tree,
2581            &progress,
2582        )
2583        .expect("warm analyzed report");
2584        pending.join().expect("nothing to save");
2585        let snapshot = progress.snapshot();
2586        assert_eq!(performance.source, ReportSource::WarmRevalidate);
2587        assert_eq!((performance.walked_files, performance.walked_bytes), (files, bytes));
2588        assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "warm revalidation");
2589        assert_eq!(snapshot.allocated, performance.walked_allocated);
2590        assert_eq!(snapshot.directories, 7);
2591        assert_eq!(performance.fresh_files, 0, "the sidecar answered every candidate");
2592        assert_eq!((snapshot.phase, snapshot.analysis), (Summarizing, Some((0, 0))));
2593    }
2594
2595    /// A cache-only report walks nothing: it ends building the answer, and its walk
2596    /// counters stay at zero.
2597    #[test]
2598    fn a_cache_only_report_ends_summarizing_and_walks_nothing() {
2599        use crate::ProgressPhase::Summarizing;
2600        let (root, _, _) = wide_tree(3, 2);
2601        let tree = Query { views: vec![ViewSpec::Tree], ..Query::default() };
2602        let cache_dir = tempfile::tempdir().expect("cache dir");
2603        let cache = cache_dir.path().join("snapshot.fdu");
2604        let (_, pending, _) =
2605            prepared(root.path(), &config(CachePolicy::On, Some(cache.clone())), &tree)
2606                .expect("a report that writes the snapshot");
2607        pending.join().expect("save");
2608
2609        let progress = Progress::new();
2610        let (_, pending, performance) = prepared_with_progress(
2611            root.path(),
2612            &stale(config(CachePolicy::Auto, Some(cache))),
2613            &tree,
2614            &progress,
2615        )
2616        .expect("cache-only report");
2617        pending.join().expect("nothing to save");
2618        let snapshot = progress.snapshot();
2619        assert_eq!(performance.source, ReportSource::CacheOnly);
2620        assert_eq!(snapshot.phase, Summarizing);
2621        assert_eq!((snapshot.directories, snapshot.files, snapshot.bytes), (0, 0, 0));
2622    }
2623
2624    /// The position of `phase` in `order`, so a poller can assert phases never go back.
2625    fn rank(phase: crate::ProgressPhase, order: &[crate::ProgressPhase]) -> usize {
2626        order
2627            .iter()
2628            .position(|expected| *expected == phase)
2629            .unwrap_or_else(|| panic!("{phase:?} is not a phase of this route"))
2630    }
2631
2632    /// What a ticker thread sees: every counter non-decreasing from one snapshot to the
2633    /// next, and the phase moving only forward through the route's order. Deterministic
2634    /// without a timing assumption, because each claim is about consecutive reads of one
2635    /// monotonic cell, whatever the interleaving; the poller just reads until the run is
2636    /// over. The tree spans many worker chunks and many small batches, so the counters
2637    /// are added to from several threads while the poller reads.
2638    #[test]
2639    fn progress_is_monotonic_and_phases_advance_in_order_while_a_report_runs() {
2640        use crate::ProgressPhase::{
2641            Analyzing, Indexing, Loading, Revalidating, Saving, Scanning, Starting, Summarizing,
2642        };
2643        let (root, files, bytes) = wide_tree(48, 6);
2644        let cache_dir = tempfile::tempdir().expect("cache dir");
2645        let cache = cache_dir.path().join("snapshot.fdu");
2646        let fixture = OpenFixture {
2647            scan: ScanConfig { threads: Some(3), batch_size: 4, ..ScanConfig::default() },
2648            ..analyzing(config(CachePolicy::Auto, Some(cache)))
2649        };
2650        let tree = Query { views: vec![ViewSpec::Tree], ..Query::default() };
2651
2652        let routes: [(&str, &[crate::ProgressPhase]); 2] = [
2653            ("cold", &[Starting, Loading, Scanning, Indexing, Analyzing, Saving, Summarizing]),
2654            ("warm", &[Starting, Loading, Revalidating, Analyzing, Saving, Summarizing]),
2655        ];
2656        for (route, order) in routes {
2657            let progress = Progress::new();
2658            // Read before the run can begin, so the first phase seen is the handle's
2659            // initial one whatever the scheduler does with the poller.
2660            let initial = progress.snapshot();
2661            let done = std::sync::atomic::AtomicBool::new(false);
2662            let (performance, seen) = std::thread::scope(|scope| {
2663                let poller = scope.spawn(|| {
2664                    let polled = progress.clone();
2665                    let mut previous = initial;
2666                    let mut seen = vec![previous.phase];
2667                    loop {
2668                        let finished = done.load(std::sync::atomic::Ordering::Acquire);
2669                        let current = polled.snapshot();
2670                        assert!(current.directories >= previous.directories, "{route}");
2671                        assert!(current.files >= previous.files, "{route}");
2672                        assert!(current.bytes >= previous.bytes, "{route}");
2673                        assert!(
2674                            rank(current.phase, order) >= rank(previous.phase, order),
2675                            "{route}: {:?} after {:?}",
2676                            current.phase,
2677                            previous.phase
2678                        );
2679                        if let (Some(before), Some(after)) = (previous.analysis, current.analysis) {
2680                            assert!(after.0 >= before.0 && after.0 <= after.1, "{route}");
2681                            assert_eq!(after.1, before.1, "{route}: the total is fixed");
2682                        }
2683                        if current.phase != previous.phase {
2684                            seen.push(current.phase);
2685                        }
2686                        previous = current;
2687                        // Read once more after the run reports done, so the final state
2688                        // is checked against the last mid-run read.
2689                        if finished {
2690                            break;
2691                        }
2692                        std::thread::yield_now();
2693                    }
2694                    seen
2695                });
2696                let (_, pending, performance) =
2697                    prepared_with_progress(root.path(), &fixture, &tree, &progress)
2698                        .expect("report");
2699                pending.join().expect("save");
2700                done.store(true, std::sync::atomic::Ordering::Release);
2701                (performance, poller.join().expect("poller"))
2702            });
2703            let snapshot = progress.snapshot();
2704            assert_eq!(
2705                (snapshot.files, snapshot.bytes),
2706                (performance.walked_files, performance.walked_bytes),
2707                "{route}"
2708            );
2709            assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "{route}");
2710            assert_eq!(snapshot.directories, 49, "{route}");
2711            assert_eq!(seen.first(), Some(&Starting), "{route}: {seen:?}");
2712            assert_eq!(
2713                seen.last().copied(),
2714                Some(snapshot.phase),
2715                "{route}: the poller saw the final phase"
2716            );
2717            assert_eq!(snapshot.phase, Summarizing, "{route}: the answer is built last");
2718            if route == "cold" {
2719                assert_eq!(snapshot.analysis, Some((files, files)));
2720            } else {
2721                assert_eq!(snapshot.analysis, Some((0, 0)));
2722                assert!(!seen.contains(&Indexing), "{route}: a warm run assembles no index");
2723            }
2724        }
2725    }
2726
2727    /// The handle observes the run and changes nothing about it: the report prepared
2728    /// with one renders to the bytes of the report prepared without, and the
2729    /// performance summary is the same value, on the indexed and the compact routes.
2730    #[test]
2731    fn a_report_prepared_with_a_handle_is_the_report_prepared_without() {
2732        let (root, _, _) = wide_tree(5, 3);
2733        let tree = Query { views: vec![ViewSpec::Tree, ViewSpec::Files], ..Query::default() };
2734        let cases = [
2735            ("full index", config(CachePolicy::Off, None), tree),
2736            ("compact summary", blind(CachePolicy::Off, None), summary_query()),
2737        ];
2738        for (route, fixture, query) in cases {
2739            let (mut plain, pending, plain_performance) =
2740                prepared(root.path(), &fixture, &query).expect("plain report");
2741            pending.join().expect("no save");
2742            let progress = Progress::new();
2743            let (observed, pending, observed_performance) =
2744                prepared_with_progress(root.path(), &fixture, &query, &progress)
2745                    .expect("observed report");
2746            pending.join().expect("no save");
2747
2748            assert_eq!(plain_performance, observed_performance, "{route}");
2749            plain.provenance = observed.provenance.clone();
2750            let json = |report: &Report| {
2751                crate::report_format::render(report, crate::report_format::Format::Json, false)
2752                    .expect("render")
2753            };
2754            assert_eq!(json(&plain), json(&observed), "{route}");
2755            assert!(progress.snapshot().files > 0, "{route}: the handle did observe the run");
2756        }
2757    }
2758}