Skip to main content

fdu_core/query/
query_report.rs

1//! Views over a built index, and the report they produce.
2//!
3//! Every view is a pure function of an index and a [`Selection`]: they read, and nothing
4//! else. Producers submit observations and the index commits them; a report can never
5//! become a third way to change state.
6//!
7//! # Two metadata query tiers
8//!
9//! An unfiltered request reads the roll-up state the index already maintains, so it costs
10//! O(directories) for a tree and O(1) for a summary regardless of how many files the tree
11//! holds. Any selection filter forces the other tier: the report walks the retained
12//! entries and re-aggregates only what the filter admits, because a pre-computed roll-up
13//! cannot answer a question about a subset. Both tiers are milliseconds warm and neither
14//! touches the filesystem; the difference is visible in a profile, not in a user's wait.
15//! Optional content I/O happens before this pure reader boundary and is retained in the
16//! index's separate derived tier.
17
18use std::borrow::Cow;
19use std::collections::{BTreeMap, BTreeSet};
20use std::path::{Path, PathBuf};
21
22use crate::classify::{ContentFamily, DetectionConfidence, DetectionSource};
23use crate::content::{AnalysisSet, ContentProvenance, CoverageReason, LogicalWordStats, MetricDef};
24use crate::control::ControlCoverage;
25use crate::engine_contract::{EntryKind, ScanScope};
26use crate::index::{EntryId, ExtTally, Index, RollUpScalars};
27use crate::query::query_request::{Basis, Request};
28use crate::query::query_selection::{
29    Bound, IgnoredEntries, NameIdentity, Selection, SizeMetric, SortKey,
30};
31use crate::query::{Rejection, ReportProvenance, TreeStatus, query_subtrees};
32
33/// Which roll-up or listing a view reports.
34#[derive(Clone, Copy, PartialEq, Eq, Debug)]
35pub enum ViewSpec {
36    /// Matching entries, rendered as a tree or a flat list by the format axis.
37    List,
38    /// Per-directory roll-ups down the hierarchy.
39    Tree,
40    /// One row per stable detected file type.
41    Types,
42    /// One row per raw derived extension.
43    Extensions,
44    /// One row per broad content family.
45    Families,
46    /// Code-family rows grouped by language/type.
47    Languages,
48    /// Prose and markup rows with text-volume metrics.
49    Documents,
50    /// A flat listing of matching entries.
51    Files,
52    /// The largest files, by size.
53    ///
54    /// A named preset over [`Self::Files`], not separate machinery:
55    /// `largest ≡ files --sort size --limit 20`, restricted to regular files.
56    Largest,
57    /// The most recently modified files.
58    ///
59    /// `recent ≡ files --sort mtime --limit 20`, restricted to regular files.
60    Recent,
61    /// One aggregate row for everything selected.
62    Summary,
63}
64
65impl ViewSpec {
66    /// The ordering this view uses when the caller did not choose one.
67    fn default_sort(self) -> SortKey {
68        match self {
69            // Size-ranked by default, because "what is big" is the question these answer.
70            Self::List
71            | Self::Tree
72            | Self::Types
73            | Self::Extensions
74            | Self::Families
75            | Self::Languages
76            | Self::Documents
77            | Self::Summary
78            // `largest` lands here for its own reason: it is named for the ranking,
79            // so the ranking is not a display default but the view's whole content.
80            | Self::Largest => SortKey::Size,
81            // A complete listing reads and diffs in name order, which is the only
82            // reason name order is right here: the stability that justifies it
83            // disappears the moment the list is truncated, which is why `files` is
84            // unbounded and the two bounded presets sort by what they are named for.
85            Self::Files => SortKey::Name,
86            Self::Recent => SortKey::Mtime,
87        }
88    }
89
90    /// The bound this view applies when the caller named none.
91    ///
92    /// `files` enumerates, so it is complete: it stands in for `fd` and `find`, and the
93    /// incremental-sync watermark query depends on it — a watermark that silently returns
94    /// twenty of 192,871 changed files loses data rather than merely under-reporting.
95    /// The presets are summaries and bound themselves; every other view keeps the
96    /// display default.
97    const fn default_limit(self) -> Bound {
98        match self {
99            Self::List | Self::Files => Bound::All,
100            Self::Largest | Self::Recent => Bound::Limit(20),
101            _ => Bound::Limit(10),
102        }
103    }
104
105    /// How deep a rendered tree descends when the caller named no depth.
106    ///
107    /// Two levels is what makes `fdu` answer "what is big here" at a glance: the root's
108    /// children and theirs. Only the tree renders a hierarchy at all, so every other
109    /// view is unbounded and the question does not arise.
110    const fn default_depth(self) -> Bound {
111        match self {
112            Self::Tree => Bound::Limit(2),
113            _ => Bound::All,
114        }
115    }
116
117    /// Whether this view reports regular files only.
118    ///
119    /// `tree` already reports directory sizes, so a `largest` that listed directories
120    /// would duplicate it at a coarser grain and push the actual files out of the window.
121    const fn files_only(self) -> bool {
122        matches!(self, Self::Largest | Self::Recent)
123    }
124
125    /// Every view, in the order a full report renders them.
126    ///
127    /// One list, so a front end cannot hold a stale copy: the Python binding kept its own
128    /// view parser and silently rejected `largest` and `recent` for exactly that reason.
129    pub const ALL: [Self; 11] = [
130        Self::List,
131        Self::Summary,
132        Self::Tree,
133        Self::Families,
134        Self::Types,
135        Self::Extensions,
136        Self::Languages,
137        Self::Documents,
138        Self::Largest,
139        Self::Recent,
140        Self::Files,
141    ];
142
143    /// Parse one view name.
144    ///
145    /// Lives here rather than in a front end because it is the axis's grammar, not one
146    /// surface's flag parsing — the CLI and the Python binding must accept exactly the
147    /// same words or the two disagree about what a request means.
148    pub fn parse(value: &str) -> Result<Self, String> {
149        match value.trim().to_ascii_lowercase().as_str() {
150            "list" => Ok(Self::List),
151            "tree" => Ok(Self::Tree),
152            "types" => Ok(Self::Types),
153            "extensions" => Ok(Self::Extensions),
154            "families" => Ok(Self::Families),
155            "languages" => Ok(Self::Languages),
156            "documents" => Ok(Self::Documents),
157            "largest" => Ok(Self::Largest),
158            "recent" => Ok(Self::Recent),
159            "files" => Ok(Self::Files),
160            "summary" => Ok(Self::Summary),
161            // The expectation only. Each front end names its own flag and quotes the
162            // offending token, so neither ends up saying "invalid --view invalid view".
163            _ => Err(format!("expected one of {}", Self::vocabulary())),
164        }
165    }
166
167    /// The accepted spellings, for an error message that teaches the vocabulary.
168    pub fn vocabulary() -> String {
169        let mut names: Vec<&str> = Self::ALL.iter().map(|view| view.label()).collect();
170        names.push("full");
171        names.join(", ")
172    }
173
174    /// Stable wire label.
175    pub const fn label(self) -> &'static str {
176        match self {
177            Self::List => "list",
178            Self::Tree => "tree",
179            Self::Types => "types",
180            Self::Extensions => "extensions",
181            Self::Families => "families",
182            Self::Languages => "languages",
183            Self::Documents => "documents",
184            Self::Largest => "largest",
185            Self::Recent => "recent",
186            Self::Files => "files",
187            Self::Summary => "summary",
188        }
189    }
190
191    /// The view a request displays its analysis in when the caller named none.
192    ///
193    /// A view may never enable an analyzer — that would let a display choice authorize
194    /// filesystem reads — but the reverse is free, because it re-projects state already
195    /// paid for. Without this, a request that reads every eligible file reports a
196    /// directory tree containing none of the results.
197    pub const fn default_for(analysis: AnalysisSet) -> Self {
198        match (analysis.includes_code(), analysis.includes_words()) {
199            (true, true) => Self::Families,
200            (true, false) => Self::Languages,
201            (false, true) => Self::Documents,
202            (false, false) if analysis.is_enabled() => Self::Families,
203            (false, false) => Self::List,
204        }
205    }
206
207    /// Resolve the view axis from a caller's spec against what the analyzers can answer.
208    ///
209    /// The whole job in one place: the list grammar, `full` expansion, and the default
210    /// when the caller named nothing. All three lived in the CLI, so `--view tree,tree`
211    /// was a typo there and a silent no-op through the Python API -- one request meaning
212    /// two things depending on which door it came through (fdu-jozr) -- and the binding
213    /// kept its own partial copy that had already drifted (fdu-ggux, fdu-gw5b).
214    ///
215    /// Returns the views to render and the ones `full` had to drop, so a caller can state
216    /// the omission rather than hide it.
217    ///
218    /// `label` names the axis as the calling surface spells it, for the reason
219    /// `AnalysisSet::parse_labeled` takes one: rewriting the message afterwards hits the
220    /// user's own token (fdu-7j6z).
221    pub fn resolve(
222        spec: Option<&str>,
223        analysis: AnalysisSet,
224        label: &str,
225    ) -> Result<(Vec<Self>, Vec<Self>), String> {
226        Self::resolve_rejecting(spec, analysis).map_err(|rejection| rejection.labeled(label))
227    }
228
229    /// [`Self::resolve`], refusing with the value and expectation rather than a sentence, so
230    /// the request model can name the axis in a typed refusal.
231    pub(crate) fn resolve_rejecting(
232        spec: Option<&str>,
233        analysis: AnalysisSet,
234    ) -> Result<(Vec<Self>, Vec<Self>), Rejection> {
235        let Some(spec) = spec else {
236            return Ok((vec![Self::default_for(analysis)], Vec::new()));
237        };
238
239        let mut parsed: Vec<Self> = Vec::new();
240        let mut full_seen = false;
241        for raw in spec.split(',') {
242            let token = raw.trim();
243            if token.is_empty() {
244                return Err(Rejection::new(spec, "empty entry in the list"));
245            }
246            if token.eq_ignore_ascii_case("full") {
247                if full_seen || !parsed.is_empty() {
248                    return Err(Rejection::new("full", Self::FULL_IS_EXCLUSIVE));
249                }
250                full_seen = true;
251                continue;
252            }
253            if full_seen {
254                return Err(Rejection::new("full", Self::FULL_IS_EXCLUSIVE));
255            }
256            let view = Self::parse(token).map_err(|expected| Rejection::new(token, expected))?;
257            if parsed.contains(&view) {
258                return Err(Rejection::new(spec, format!("{token:?} appears more than once")));
259            }
260            parsed.push(view);
261        }
262
263        if full_seen {
264            return Ok(Self::full_report(analysis));
265        }
266        Ok((parsed, Vec::new()))
267    }
268
269    /// Why `full` cannot appear beside another view.
270    ///
271    /// Stated once, here, because it was stated twice: the CLI and the Python binding
272    /// each carried their own copy, and the binding's had lost the trailing clause. Two
273    /// copies of one rule drift silently, and a parity test comparing surface to surface
274    /// only catches it when the drift reaches the wording (fdu-gw5b).
275    pub const FULL_IS_EXCLUSIVE: &'static str =
276        "it names the whole report and cannot be combined with another view";
277
278    /// The summary views `full` expands to, given what the analyzers can answer.
279    ///
280    /// Returns the satisfiable views and those it had to skip, so a caller can report the
281    /// omission rather than drop it silently.
282    pub fn full_report(analysis: AnalysisSet) -> (Vec<Self>, Vec<Self>) {
283        Self::ALL
284            .into_iter()
285            .filter(|view| view.is_summary_view())
286            .partition(|view| !matches!(view, Self::Documents) || analysis.is_enabled())
287    }
288
289    /// Whether this view belongs in `--view full`.
290    ///
291    /// `full` is every *summary* view. `files` is an unbounded enumeration, and putting
292    /// one inside a digest destroys the digest.
293    pub const fn is_summary_view(self) -> bool {
294        !matches!(self, Self::List | Self::Files)
295    }
296}
297
298/// What the calling surface calls the knobs a report's diagnostics name.
299///
300/// The same reason `ViewSpec::resolve` and `AnalysisSet::parse_labeled` take a label: a
301/// rule belongs to the library, but the words a caller can act on belong to the surface
302/// they came through. Telling a Python caller to "add --analyze" names a flag that does
303/// not exist in their surface -- the defect that made these messages worth moving here in
304/// the first place, reappearing one door over (fdu-4apt).
305///
306/// The view and analyzer axes both, because both diagnostics name both: the view that
307/// cannot be answered, and the analyzer that would answer it. The two control limits,
308/// because the note about refused `.gitignore` files names the limit that applies them.
309/// The ignored-state selections and the observation switch, because selecting by ignored
310/// state in a scan that reads no `.gitignore` is refused by naming both.
311///
312/// And every other axis a [`RequestError`](crate::query::RequestError) names: the value
313/// grammars of the request model reject a value by naming its axis, and the watch
314/// refusals name the knobs a watch cannot honor, so one type states each rule and each
315/// surface supplies only its words.
316#[derive(Clone, Copy, Debug, PartialEq, Eq)]
317pub struct AxisNames {
318    /// The view axis.
319    pub view: &'static str,
320    /// The output format axis.
321    pub format: &'static str,
322    /// The analyzer axis.
323    pub analyze: &'static str,
324    /// The budget on retained `.gitignore` state.
325    pub control_budget: &'static str,
326    /// The limit on one `.gitignore` line.
327    pub control_line_limit: &'static str,
328    /// The selection of unignored entries only.
329    pub exclude_ignored: &'static str,
330    /// The selection of ignored entries only.
331    pub only_ignored: &'static str,
332    /// The switch that turns `.gitignore` observation off.
333    pub read_controls: &'static str,
334    /// The retention depth of a scan.
335    pub scan_depth: &'static str,
336    /// The switch that keeps a scan on the root's filesystem.
337    pub one_filesystem: &'static str,
338    /// The switch that walks into what a symbolic link points at.
339    pub follow_symlinks: &'static str,
340    /// The patterns an entry must match.
341    pub include: &'static str,
342    /// The inclusive lower bound on modification time.
343    pub modified_since: &'static str,
344    /// The exclusive upper bound on modification time.
345    pub modified_before: &'static str,
346    /// The entry kinds a selection admits.
347    pub kind: &'static str,
348    /// The selection by ignored state, as one axis.
349    pub ignored: &'static str,
350    /// How deep a rendered tree descends.
351    pub depth: &'static str,
352    /// How many rows a view keeps.
353    pub limit: &'static str,
354    /// The ordering key.
355    pub sort: &'static str,
356    /// The size metric.
357    pub size: &'static str,
358    /// The logical-word denominator of a document page.
359    pub words_per_page: &'static str,
360    /// The cache policy.
361    pub cache: &'static str,
362    /// The request to repeat the answer as a watch.
363    pub watch: &'static str,
364}
365
366impl AxisNames {
367    /// How the command line spells them.
368    ///
369    /// `ignored` is the one axis the command line splits into two switches, so it names
370    /// both; it only reaches a diagnostic through a value no flag can produce.
371    ///
372    /// `follow_symlinks` is the one axis this surface cannot set at all, so it keeps the
373    /// library's name: a request carrying it came from a library or `open` caller, and
374    /// naming a `--follow-symlinks` that does not exist would point them at the wrong door.
375    pub const FLAGS: Self = Self {
376        view: "--view",
377        format: "--format",
378        analyze: "--analyze",
379        control_budget: "--gitignore-budget",
380        control_line_limit: "--gitignore-line-limit",
381        exclude_ignored: "--exclude-ignored",
382        only_ignored: "--only-ignored",
383        read_controls: "--no-gitignore",
384        scan_depth: "--scan-depth",
385        one_filesystem: "--one-filesystem",
386        follow_symlinks: "follow_symlinks",
387        include: "--include",
388        modified_since: "--modified-since",
389        modified_before: "--modified-before",
390        kind: "--kind",
391        ignored: "--exclude-ignored/--only-ignored",
392        depth: "--depth",
393        limit: "--limit",
394        sort: "--sort",
395        size: "--size",
396        words_per_page: "--words-per-page",
397        cache: "--cache",
398        watch: "--watch",
399    };
400
401    /// How the library and the Python API spell them, and the default: a `Query` built
402    /// without saying otherwise belongs to a library caller, not to the command line.
403    ///
404    /// `view` singular, matching the label the binding already passes to
405    /// `ViewSpec::resolve`, so every diagnostic about this axis names it one way. It also
406    /// keeps the difference from the command line to the flag dashes alone, which is what
407    /// the parity harness's `surface-label` class checks.
408    ///
409    /// `cache` is the one name that is not a field: the Python parameter is `cache`, but its
410    /// refusal has always said `invalid cache policy`, and this type moved the wording
411    /// without changing it.
412    pub const FIELDS: Self = Self {
413        view: "view",
414        format: "format",
415        analyze: "analyze",
416        control_budget: "control_budget",
417        control_line_limit: "control_line_limit",
418        exclude_ignored: "ignored=exclude",
419        only_ignored: "ignored=only",
420        read_controls: "read_controls",
421        scan_depth: "max_depth",
422        one_filesystem: "one_filesystem",
423        follow_symlinks: "follow_symlinks",
424        include: "include",
425        modified_since: "modified_since",
426        modified_before: "modified_before",
427        kind: "kind",
428        ignored: "ignored",
429        depth: "depth",
430        limit: "limit",
431        sort: "sort",
432        size: "size",
433        words_per_page: "words_per_page",
434        cache: "cache policy",
435        watch: "watch",
436    };
437}
438
439impl Default for AxisNames {
440    fn default() -> Self {
441        Self::FIELDS
442    }
443}
444
445/// What a report was asked for.
446#[derive(Clone, Debug)]
447pub struct Query {
448    /// Which entries to consider and how to shape results.
449    pub selection: Selection,
450    /// Which views to report, in the order they were requested.
451    pub views: Vec<ViewSpec>,
452    /// Presentation requested before projecting retained entries.
453    pub format: crate::report_format::Format,
454    /// Views `full` had to drop because the requested analyzers cannot answer them.
455    ///
456    /// Carried so the report can name the omission rather than leave a caller to notice a
457    /// section is missing. It lived in the CLI, which meant only the CLI could tell anyone
458    /// (fdu-x8u6); `ViewSpec::resolve` returns it and this is where it lands.
459    pub omitted_views: Vec<ViewSpec>,
460    /// What the requesting surface calls the axes its diagnostics name.
461    ///
462    /// Carried on the request because that is what knows which door the caller came
463    /// through; the rules themselves stay here and are each stated once.
464    pub axes: &'static AxisNames,
465    /// Fixed logical-word denominator used to derive page equivalents after aggregation.
466    pub words_per_page: u64,
467}
468
469impl Default for Query {
470    fn default() -> Self {
471        Self {
472            selection: Selection::default(),
473            views: Vec::new(),
474            format: crate::report_format::Format::Text,
475            omitted_views: Vec::new(),
476            axes: &AxisNames::FIELDS,
477            words_per_page: crate::query::Request::DEFAULTS.words_per_page,
478        }
479    }
480}
481
482impl Query {
483    /// Whether this view needs the directory hierarchy rather than matching flat rows.
484    pub fn tree_for(&self, view: ViewSpec) -> bool {
485        use crate::report_format::Format;
486        match view {
487            ViewSpec::List => matches!(self.format, Format::Text | Format::Tree),
488            ViewSpec::Tree => !matches!(self.format, Format::Paths | Format::Long),
489            ViewSpec::Files => self.format == Format::Tree,
490            _ => false,
491        }
492    }
493
494    /// Whether this read needs directory candidates and their subtree measurements.
495    pub(crate) fn needs_selection_walk(&self) -> bool {
496        !self.selection.is_unfiltered()
497            || self.views.iter().any(|view| {
498                matches!(view, ViewSpec::List | ViewSpec::Tree | ViewSpec::Files)
499                    && !self.tree_for(*view)
500            })
501    }
502
503    /// The bound to apply for `view`: the caller's if they named one, else the view's own.
504    pub fn limit_for(&self, view: ViewSpec) -> Bound {
505        self.selection.limit.unwrap_or_else(|| {
506            if self.tree_for(view) {
507                ViewSpec::Tree.default_limit()
508            } else if view == ViewSpec::Tree {
509                Bound::All
510            } else {
511                view.default_limit()
512            }
513        })
514    }
515
516    /// The tree depth to apply for `view`, on the same terms as `limit_for`.
517    pub fn depth_for(&self, view: ViewSpec) -> Bound {
518        self.selection.depth.unwrap_or_else(|| {
519            if self.tree_for(view) { ViewSpec::Tree.default_depth() } else { view.default_depth() }
520        })
521    }
522}
523
524/// Which tier of the freshness ladder produced the index behind a report.
525#[derive(Clone, Copy, PartialEq, Eq, Debug)]
526pub enum ReportSource {
527    /// The tree was walked from scratch.
528    ColdScan,
529    /// A snapshot was loaded and revalidated against the filesystem.
530    WarmRevalidate,
531    /// A snapshot answered without the filesystem being consulted.
532    CacheOnly,
533}
534
535/// One directory's row in a tree view.
536#[derive(Clone, Debug)]
537pub struct TreeNode {
538    /// Path relative to the index root; empty for the root itself.
539    pub path: PathBuf,
540    /// Final path component, or `.` for the root.
541    pub name: String,
542    /// What the entry is.
543    pub kind: EntryKind,
544    /// Apparent bytes in this subtree.
545    pub bytes: u64,
546    /// Allocated bytes in this subtree.
547    pub allocated: u64,
548    /// Files in this subtree.
549    pub files: u64,
550    /// Directories in this subtree.
551    pub dirs: u64,
552    /// The part of this subtree's tallies that `.gitignore` rules ignore, or `None` when
553    /// the index observed no control state.
554    ///
555    /// Counted over the selected entries, like every other tally on the row, so it is zero
556    /// when the selection excludes ignored entries and the whole row when it admits only
557    /// them.
558    pub ignored: Option<IgnoredTally>,
559    /// Newest modification time in this subtree, when it holds any files.
560    pub newest_mtime_ns: Option<i64>,
561    /// Children reported beneath this node.
562    pub children: Vec<TreeNode>,
563    /// Whether children were withheld by the depth or limit bound.
564    pub truncated: bool,
565}
566
567impl Drop for TreeNode {
568    /// Release children iteratively.
569    ///
570    /// The derived drop glue recurses once per level, so a deeply nested tree would
571    /// exhaust the stack on release even after every renderer was made iterative — the
572    /// same hazard the index avoids when freeing a subtree. Taking the children out first
573    /// turns that recursion into a loop.
574    fn drop(&mut self) {
575        let mut pending = std::mem::take(&mut self.children);
576        while let Some(mut node) = pending.pop() {
577            pending.extend(std::mem::take(&mut node.children));
578        }
579    }
580}
581
582/// The part of a row's tallies that `.gitignore` rules ignore.
583///
584/// An entry is ignored when a rule matches it or any ancestor directory, as git cannot
585/// re-include a file below an excluded directory. Below a refused `.gitignore`
586/// ([`Report::ignore_rules`]) the split is not exact in either direction; the sizes it
587/// divides are.
588#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
589pub struct IgnoredTally {
590    /// Ignored files.
591    pub files: u64,
592    /// Ignored directories. Always zero on an extension row, which counts files only.
593    pub dirs: u64,
594    /// Apparent bytes across ignored files.
595    pub bytes: u64,
596    /// Allocated bytes across ignored files.
597    pub allocated: u64,
598}
599
600impl IgnoredTally {
601    /// The ignored share of a roll-up: what `all` holds beyond `unignored`.
602    pub(crate) fn between(all: RollUpScalars, unignored: RollUpScalars) -> Self {
603        Self {
604            files: all.files.saturating_sub(unignored.files),
605            dirs: all.dirs.saturating_sub(unignored.dirs),
606            bytes: all.bytes.saturating_sub(unignored.bytes),
607            allocated: all.allocated.saturating_sub(unignored.allocated),
608        }
609    }
610
611    fn add(&mut self, other: Self) {
612        self.files = self.files.saturating_add(other.files);
613        self.dirs = self.dirs.saturating_add(other.dirs);
614        self.bytes = self.bytes.saturating_add(other.bytes);
615        self.allocated = self.allocated.saturating_add(other.allocated);
616    }
617}
618
619/// One extension's row in a types view.
620#[derive(Clone, Debug)]
621pub struct TypeRow {
622    /// The derived extension, including its leading dot.
623    pub extension: String,
624    /// Files with this extension.
625    pub files: u64,
626    /// Apparent bytes across those files.
627    pub bytes: u64,
628    /// Allocated bytes across those files.
629    pub allocated: u64,
630    /// The ignored part of this row, or `None` when the index observed no control state.
631    pub ignored: Option<IgnoredTally>,
632}
633
634/// Dimension used by a generic metric-summary section.
635#[derive(Clone, Copy, PartialEq, Eq, Debug)]
636pub enum MetricGroup {
637    /// Stable detected file type or language ID.
638    Type,
639    /// Broad code/prose/markup/data/binary family.
640    Family,
641}
642
643/// Exact share represented as an integer fraction.
644#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
645pub struct MetricShare {
646    /// Selected size contributed by this row.
647    pub numerator: u64,
648    /// Selected size across every row before display truncation.
649    pub denominator: u64,
650}
651
652/// Metric used as the numerator and denominator of grouped percentages.
653#[derive(Clone, Copy, PartialEq, Eq, Debug)]
654pub enum ShareMetric {
655    /// Apparent file bytes selected by the query.
656    ApparentBytes,
657    /// Allocated filesystem bytes selected by the query.
658    AllocatedBytes,
659    /// Standard code lines from `code-sloc-v1`.
660    CodeLines,
661    /// Raw or reader-visible normalized document words, selected by analysis depth.
662    DocumentWords,
663    /// Whitespace-delimited words from the shared lines unit.
664    RawWords,
665}
666
667impl ShareMetric {
668    /// Stable machine label.
669    pub const fn as_str(self) -> &'static str {
670        match self {
671            Self::ApparentBytes => "apparent_bytes",
672            Self::AllocatedBytes => "allocated_bytes",
673            Self::CodeLines => "code_lines",
674            Self::DocumentWords => "document_words",
675            Self::RawWords => "raw_words",
676        }
677    }
678}
679
680/// One stable group in a metric-summary section.
681#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
682pub struct ReportMetricValues {
683    /// Physical lines, present with the lines unit.
684    pub physical_lines: Option<u64>,
685    /// Blank lines, present with the lines unit.
686    pub blank_lines: Option<u64>,
687    /// Nonblank lines, present with the lines unit.
688    pub nonblank_lines: Option<u64>,
689    /// Raw words, present with the lines unit.
690    pub raw_words: Option<u64>,
691    /// Code lines, present with the code unit.
692    pub code_lines: Option<u64>,
693    /// Comment lines, present with the code unit.
694    pub comment_lines: Option<u64>,
695    /// Code-analyzer blank lines, present with the code unit.
696    pub code_blank_lines: Option<u64>,
697    /// Logical words, present with the words unit.
698    pub logical_words: Option<u64>,
699    /// Paragraphs, present with the words unit.
700    pub paragraphs: Option<u64>,
701    /// Reader-visible words, present with the words unit.
702    pub visible_words: Option<u64>,
703    /// Reader-visible logical words, present with the words unit.
704    pub visible_logical_words: Option<u64>,
705    /// Query-selected document words, present with the words unit.
706    pub document_words: Option<u64>,
707}
708
709impl ReportMetricValues {
710    fn for_analysis(analysis: AnalysisSet) -> Self {
711        let lines = analysis.is_enabled().then_some(0);
712        let code = analysis.includes_code().then_some(0);
713        let words = analysis.includes_words().then_some(0);
714        Self {
715            physical_lines: lines,
716            blank_lines: lines,
717            nonblank_lines: lines,
718            raw_words: lines,
719            code_lines: code,
720            comment_lines: code,
721            code_blank_lines: code,
722            logical_words: words,
723            paragraphs: words,
724            visible_words: words,
725            visible_logical_words: words,
726            document_words: words,
727        }
728    }
729
730    fn add_assign(&mut self, other: &Self) {
731        add_optional(&mut self.physical_lines, other.physical_lines);
732        add_optional(&mut self.blank_lines, other.blank_lines);
733        add_optional(&mut self.nonblank_lines, other.nonblank_lines);
734        add_optional(&mut self.raw_words, other.raw_words);
735        add_optional(&mut self.code_lines, other.code_lines);
736        add_optional(&mut self.comment_lines, other.comment_lines);
737        add_optional(&mut self.code_blank_lines, other.code_blank_lines);
738        add_optional(&mut self.paragraphs, other.paragraphs);
739        add_optional(&mut self.visible_words, other.visible_words);
740    }
741}
742
743fn add_optional(total: &mut Option<u64>, value: Option<u64>) {
744    if let (Some(total), Some(value)) = (total, value) {
745        *total = total.saturating_add(value);
746    }
747}
748
749/// One stable group in a metric-summary section.
750#[derive(Clone, Debug)]
751pub struct MetricRow {
752    /// Analyzer units requested for this row.
753    pub analysis: AnalysisSet,
754    /// Stable type or family label.
755    pub id: String,
756    /// Broad family for type-grouped rows.
757    pub family: ContentFamily,
758    /// Matching regular files.
759    pub files: u64,
760    /// Apparent bytes.
761    pub bytes: u64,
762    /// Allocated bytes.
763    pub allocated: u64,
764    /// Files whose requested metrics completed.
765    pub analyzed_files: u64,
766    /// Additive content metric slots.
767    pub metrics: ReportMetricValues,
768    /// Additive sufficient statistics behind `logical_words`.
769    pub(crate) logical_word_stats: LogicalWordStats,
770    /// Additive sufficient statistics behind `visible_logical_words`.
771    pub(crate) visible_logical_word_stats: LogicalWordStats,
772    /// Query-selected raw document words before normalization.
773    pub document_raw_words: u64,
774    /// Additive sufficient statistics for the query-selected document projection.
775    pub document_word_stats: LogicalWordStats,
776    /// Analyzed files that supplied normalized document statistics.
777    pub document_metric_files: u64,
778    /// Explicit content-analysis outcomes.
779    pub coverage: BTreeMap<CoverageReason, u64>,
780    /// Lines-unit outcomes.
781    pub lines_coverage: BTreeMap<CoverageReason, u64>,
782    /// Code-unit outcomes when requested.
783    pub code_coverage: Option<BTreeMap<CoverageReason, u64>>,
784    /// Words-unit outcomes when requested.
785    pub words_coverage: Option<BTreeMap<CoverageReason, u64>>,
786    /// Files by the classification tier that established their type.
787    pub detection_sources: BTreeMap<DetectionSource, u64>,
788    /// Files by classification confidence.
789    pub detection_confidence: BTreeMap<DetectionConfidence, u64>,
790    /// Files carrying a bounded generated-file marker.
791    pub generated_files: u64,
792    /// Files below a conventional vendored path.
793    pub vendored_files: u64,
794    /// Files below a conventional documentation path or basename.
795    pub documentation_files: u64,
796    /// Exact share in the report's selected size metric.
797    pub share: MetricShare,
798}
799
800impl MetricRow {
801    /// Return one registry metric when its owning analyzer unit was requested.
802    pub fn metric_value(&self, metric: &MetricDef) -> Option<u64> {
803        if !self.analysis.contains(metric.owner) {
804            return None;
805        }
806        match metric.name {
807            "physical_lines" => self.metrics.physical_lines,
808            "blank_lines" => self.metrics.blank_lines,
809            "nonblank_lines" => self.metrics.nonblank_lines,
810            "raw_words" => self.metrics.raw_words,
811            "code_lines" => self.metrics.code_lines,
812            "comment_lines" => self.metrics.comment_lines,
813            "code_blank_lines" => self.metrics.code_blank_lines,
814            "logical_words" => self.metrics.logical_words,
815            "paragraphs" => self.metrics.paragraphs,
816            "visible_words" => self.metrics.visible_words,
817            "visible_logical_words" => self.metrics.visible_logical_words,
818            "document_words" => self.metrics.document_words,
819            _ => None,
820        }
821    }
822
823    fn finish_derived_metrics(&mut self) {
824        if self.analysis.includes_words() {
825            self.metrics.logical_words = Some(self.logical_word_stats.logical_words());
826            self.metrics.visible_logical_words =
827                Some(self.visible_logical_word_stats.logical_words());
828            self.metrics.document_words = Some(self.document_word_stats.logical_words());
829        }
830    }
831}
832
833/// Totals and grouped rows for types, families, languages, or documents.
834#[derive(Clone, Debug)]
835pub struct MetricSummary {
836    /// Grouping dimension.
837    pub group: MetricGroup,
838    /// Totals across every row before display truncation.
839    pub total: MetricRow,
840    /// Sorted, display-bounded rows.
841    pub rows: Vec<MetricRow>,
842    /// Rows before the bound was applied.
843    pub total_rows: usize,
844    /// Metric used for every row's exact share.
845    pub share_metric: ShareMetric,
846    /// Logical words per derived page.
847    pub words_per_page: u64,
848}
849
850/// Analyzer identity attached to a content-capable report.
851#[derive(Clone, Debug)]
852pub struct ContentReportMetadata {
853    /// Requested analysis profile.
854    pub profile: AnalysisSet,
855    /// Type-rule, option, and analyzer dialect identity.
856    pub provenance: ContentProvenance,
857}
858
859/// One matching entry in a flat view. Directory size and mtime describe its subtree
860/// after exclusions; other entries carry their own metadata. Nested rows may overlap.
861#[derive(Clone, Debug)]
862pub struct FileRow {
863    /// Path relative to the index root.
864    pub path: PathBuf,
865    /// What the entry is.
866    pub kind: EntryKind,
867    /// Apparent bytes.
868    pub bytes: u64,
869    /// Allocated bytes.
870    pub allocated: u64,
871    /// Modification time in nanoseconds since the Unix epoch.
872    pub mtime_ns: i64,
873    /// Descendant regular files for a directory; absent for other entry kinds.
874    pub files: Option<u64>,
875    /// Descendant directories, excluding the matching root; absent for other kinds.
876    pub dirs: Option<u64>,
877    /// Whether a directory's eligible subtree was listed in full, so its bytes, counts,
878    /// and modification time are exact; `Some(false)` makes them lower bounds and its age
879    /// unknown. Absent for other kinds.
880    pub complete: Option<bool>,
881    /// Signed nanoseconds since modification at the request's reference instant, or
882    /// `None` when the reference is unrepresentable or the subtree is incomplete.
883    pub age_ns: Option<i128>,
884    /// Whether `.gitignore` rules ignore this entry, or `None` when the index observed no
885    /// control state.
886    pub ignored: Option<bool>,
887}
888
889/// The aggregate row of a summary view.
890#[derive(Clone, Copy, Debug, Default)]
891pub struct SummaryRow {
892    /// Files selected.
893    pub files: u64,
894    /// Directories selected.
895    pub dirs: u64,
896    /// Apparent bytes.
897    pub bytes: u64,
898    /// Allocated bytes.
899    pub allocated: u64,
900    /// The ignored part of what was selected, or `None` when the index observed no control
901    /// state.
902    pub ignored: Option<IgnoredTally>,
903    /// Newest modification time, when anything was selected.
904    pub newest_mtime_ns: Option<i64>,
905}
906
907/// One view's results.
908#[derive(Clone, Debug)]
909pub enum Section {
910    /// A tree view.
911    Tree {
912        /// The view whose directory hierarchy is shown.
913        view: ViewSpec,
914        /// The bounded directory roll-ups.
915        root: TreeNode,
916    },
917    /// A raw-extension view.
918    Extensions {
919        /// The rows, already sorted and bounded.
920        rows: Vec<TypeRow>,
921        /// Rows before the bound was applied.
922        total: usize,
923    },
924    /// A generic type/family content summary.
925    Metrics {
926        /// Requested preset that selected grouping and family filters.
927        view: ViewSpec,
928        /// Generic grouped metrics.
929        summary: Box<MetricSummary>,
930    },
931    /// A flat listing: `files`, or one of its bounded presets.
932    ///
933    /// Carries the view for the same reason `Metrics` does — three views share this shape
934    /// and a reader has to be told which one produced the rows.
935    Files {
936        /// Which of `files`, `largest`, or `recent` produced these rows.
937        view: ViewSpec,
938        /// The rows, already sorted and bounded for that view.
939        rows: Vec<FileRow>,
940        /// Rows before the bound was applied.
941        ///
942        /// Reported so a bound can never be silent: twenty rows of 192,871 look complete
943        /// unless the report says otherwise, and a consumer reading the machine format
944        /// has no other way to tell.
945        total: usize,
946    },
947    /// A summary view.
948    Summary(SummaryRow),
949}
950
951impl Section {
952    /// Which view produced this section.
953    pub fn view(&self) -> ViewSpec {
954        match self {
955            Self::Extensions { .. } => ViewSpec::Extensions,
956            Self::Tree { view, .. } | Self::Metrics { view, .. } | Self::Files { view, .. } => {
957                *view
958            }
959            Self::Summary(_) => ViewSpec::Summary,
960        }
961    }
962}
963
964/// A rendered answer: provenance, plus one section per requested view.
965#[derive(Clone, Debug)]
966pub struct Report {
967    /// Instant used for modification age, in epoch nanoseconds; unknown outside i64.
968    pub age_reference_ns: Option<i64>,
969    /// Requested presentation; Text resolves from each section projection.
970    pub format: crate::report_format::Format,
971    /// Completeness and bounded failure detail for this answer.
972    pub status: TreeStatus,
973    /// Source, currency, and timing for this answer and its retained tiers.
974    pub provenance: ReportProvenance,
975    /// The semantic scan scope represented by this report.
976    ///
977    /// A projected cache load constructs its index directly in the requested controls-off
978    /// scope, so every report route reads this value from the same requested-scope index.
979    pub scope: ScanScope,
980    /// Analyzer units requested for this answer.
981    pub requested_analysis: AnalysisSet,
982    /// Resolved views requested and answerable by this analyzer set.
983    pub requested_views: Vec<ViewSpec>,
984    /// Resolved views requested but unavailable from this analyzer set.
985    pub omitted_views: Vec<ViewSpec>,
986    /// Absolute path of the indexed root.
987    pub root: PathBuf,
988    /// Remarks about the report itself, in the order a renderer should print them.
989    ///
990    /// Facts about what was asked for and what could be answered -- not telemetry about
991    /// the run, which the schema deliberately excludes. Deliberately not serialised: a
992    /// machine consumer reads the omission from which sections are absent, and adding a
993    /// field to the envelope would be a schema change for something only humans read.
994    pub notes: Vec<String>,
995    /// Which size metric this report answers in.
996    ///
997    /// Carried on the report so a renderer shows the same number the ordering used;
998    /// printing apparent bytes beside an allocated-bytes ranking looks like a sorting
999    /// bug and is worse than either metric alone.
1000    pub size: SizeMetric,
1001    /// Analyzer identity when sparse content records are present.
1002    pub analysis: Option<ContentReportMetadata>,
1003    /// Which entries the rows count by `.gitignore` classification.
1004    ///
1005    /// Carried for the renderer, like [`Self::size`], and not serialised: a row whose
1006    /// selection admits only ignored entries is wholly ignored, so text leaves the ignored
1007    /// share off rather than repeat the size beside it.
1008    pub ignored_entries: IgnoredEntries,
1009    /// Whether ignore classification applies every `.gitignore` in scope, serialised as
1010    /// `ignore_rules`.
1011    ///
1012    /// Not operational completeness: a refused control file leaves [`TreeStatus::complete`]
1013    /// true and every size exact, and costs only the ignored and unignored split below
1014    /// it. [`Self::notes`] names the directories and the knob that applies them.
1015    pub ignore_rules: ControlCoverage,
1016    /// One section per requested view, in request order.
1017    pub sections: Vec<Section>,
1018}
1019
1020/// Remarks a report makes about itself.
1021///
1022/// Only what the request, the resolved views, and the index's coverage can establish. The
1023/// CLI also prints a note quoting how many bytes analysis read, which is walk telemetry the
1024/// report envelope does not carry, so that one stays with the performance footer where the
1025/// rest of the run's telemetry lives.
1026pub(crate) fn display_notes(query: &Query, ignore_rules: &ControlCoverage) -> Vec<String> {
1027    let mut notes = Vec::new();
1028    if !query.omitted_views.is_empty() {
1029        let names: Vec<&str> = query.omitted_views.iter().map(|view| view.label()).collect();
1030        notes.push(format!(
1031            "note: omitted {} — requires content analysis: add {} lines, code, words, or all",
1032            names.join(", "),
1033            query.axes.analyze
1034        ));
1035    }
1036    notes.extend(refused_controls_note(ignore_rules, query.axes));
1037    notes
1038}
1039
1040/// Directories a refused-controls note names before it counts the rest.
1041const REFUSED_DIRECTORIES_NAMED: usize = 5;
1042
1043/// Say which `.gitignore` files were not applied, why, where, and what applies them.
1044///
1045/// The truncation states itself: the note names a few directories and counts the rest,
1046/// and a structured report lists up to [`crate::MAX_RETAINED_ISSUES`] beside the exact
1047/// count. Each limit is named with the refusals it caused only when every refusal is
1048/// listed; otherwise the note names every limit that could have refused an unlisted file.
1049/// The remedy raises exactly the limits it named, each by the name the requesting surface
1050/// uses, so lifting one never reads as lifting the other.
1051fn refused_controls_note(ignore_rules: &ControlCoverage, axes: &AxisNames) -> Option<String> {
1052    use crate::control::ControlRefusalReason::{Budget, LineLimit};
1053
1054    let ControlCoverage::Observed(observed) = ignore_rules else {
1055        return None;
1056    };
1057    if observed.is_complete() {
1058        return None;
1059    }
1060    let every_listed = observed.lists_every_refusal();
1061    let listed =
1062        |reason| observed.refusals.iter().filter(|refusal| refusal.reason == reason).count();
1063    // A listed reason certainly fired. When the list is truncated, a bounded limit may also
1064    // have refused an unlisted file; an unbounded one refuses nothing.
1065    let fired: Vec<_> = [Budget, LineLimit]
1066        .into_iter()
1067        .filter(|reason| {
1068            listed(*reason) > 0 || (!every_listed && observed.limits.limit_for(*reason).is_some())
1069        })
1070        .collect();
1071    let size = |reason| {
1072        observed.limits.limit_for(reason).map(|bytes| {
1073            crate::report_format::human_bytes(u64::try_from(bytes).unwrap_or(u64::MAX))
1074        })
1075    };
1076    // A refusal recorded under an unbounded limit names the limit without a size, never a
1077    // zero one.
1078    let over = |reason| {
1079        let (lead, noun) = match reason {
1080            Budget => ("over", "ignore-rule budget"),
1081            LineLimit => ("with a line over", "line limit"),
1082        };
1083        size(reason).map_or_else(
1084            || format!("{lead} the {noun}"),
1085            |size| format!("{lead} the {size} {noun}"),
1086        )
1087    };
1088    let why = if every_listed {
1089        let parts: Vec<String> =
1090            fired.iter().map(|reason| format!("{} {}", listed(*reason), over(*reason))).collect();
1091        parts.join(", ")
1092    } else {
1093        let parts: Vec<String> = fired.iter().map(|reason| over(*reason)).collect();
1094        parts.join(" or ")
1095    };
1096
1097    let shown = observed.refusals.len().min(REFUSED_DIRECTORIES_NAMED);
1098    let mut directories: Vec<String> = observed.refusals[..shown]
1099        .iter()
1100        .map(|refusal| match refusal.path.parent() {
1101            Some(parent) if !parent.as_os_str().is_empty() => parent.display().to_string(),
1102            _ => ".".to_string(),
1103        })
1104        .collect();
1105    let unnamed = observed.refused.saturating_sub(u64::try_from(shown).unwrap_or(u64::MAX));
1106    if unnamed > 0 {
1107        directories.push(format!("{} more", crate::report_format::human_count(unnamed)));
1108    }
1109
1110    // Only a bounded limit can be raised.
1111    let raises: Vec<String> = fired
1112        .iter()
1113        .filter_map(|reason| {
1114            let knob = match reason {
1115                Budget => axes.control_budget,
1116                LineLimit => axes.control_line_limit,
1117            };
1118            size(*reason).map(|size| format!("{knob} above {size}"))
1119        })
1120        .collect();
1121    let remedy = match raises.as_slice() {
1122        [] => String::new(),
1123        [raise] => format!(" To apply them, raise {raise}, or set it to all"),
1124        raises => format!(" To apply them, raise {}, or set them to all", raises.join(" and ")),
1125    };
1126    let files = crate::report_format::human_count(observed.refused);
1127    let noun = if observed.refused == 1 { "file" } else { "files" };
1128    Some(format!(
1129        "note: {files} .gitignore {noun} not applied ({why}), so ignored shares under {} are \
1130         not exact; sizes are.{remedy}",
1131        directories.join(", ")
1132    ))
1133}
1134
1135/// Build a report from an index.
1136///
1137/// Pure: the same index, request, and provenance always produce the same report, and
1138/// nothing here reads the filesystem or mutates the index.
1139///
1140/// # Errors
1141///
1142/// [`Error::InvalidRequest`](crate::Error::InvalidRequest) when this index cannot answer
1143/// the request: it holds another analyzer set than the read asks for, a view needs content
1144/// nothing analyzed, or the request selects by ignored state
1145/// ([`Selection::ignored`]) over an index that read no `.gitignore` -- which can say of no
1146/// entry that it is ignored or that it is not, so the request is refused rather than
1147/// answered with every entry or none.
1148pub fn report(
1149    index: &Index,
1150    request: &Request,
1151    generated_at: std::time::SystemTime,
1152) -> crate::Result<Report> {
1153    report_in(index, request, generated_at, NameIdentity::Native)
1154}
1155
1156/// [`report`], with the selection evaluated against the named spelling of each path.
1157///
1158/// A one-shot report matches native names; an opened-root read matches portable ones, so
1159/// its report projection agrees with its flat and aggregate projections over one query.
1160pub(crate) fn report_in(
1161    index: &Index,
1162    request: &Request,
1163    generated_at: std::time::SystemTime,
1164    identity: NameIdentity,
1165) -> crate::Result<Report> {
1166    // What this index holds is what it can be read for. Every surface validates before it
1167    // scans, in the vocabulary its own caller uses; this is the library path, and the last
1168    // one, so nothing produces an answer from an unvalidated request.
1169    request.validate_read(&Basis::held_by(index)).map_err(crate::Error::InvalidRequest)?;
1170
1171    let query = &request.query;
1172    let content = request.basis.content;
1173    // One traversal serves every filtered view in the request, so asking for three views
1174    // costs one pass rather than three.
1175    let walked = query.needs_selection_walk().then(|| walk(index, &query.selection, identity));
1176    // Unfiltered metric and file views share one `FileRow` walk only when more than one
1177    // section consumes it. A single section keeps ownership of its one traversal, so a
1178    // bounded file view does not clone every path before sorting and truncating it.
1179    // Summary, Tree, and Extensions keep roll-ups when unfiltered and do not consume rows.
1180    let row_consumers =
1181        query.views.iter().copied().filter(|view| needs_unfiltered_entry_rows(*view)).count();
1182    let unfiltered_rows = (walked.is_none() && row_consumers > 1).then(|| every_entry(index));
1183
1184    let mut sections: Vec<Section> = query
1185        .views
1186        .iter()
1187        .map(|view| {
1188            build_section(*view, index, query, content, walked.as_ref(), unfiltered_rows.as_deref())
1189        })
1190        .collect();
1191
1192    let age_reference_ns = crate::query::system_time_to_nanos(request.now);
1193    for section in &mut sections {
1194        if let Section::Files { rows, .. } = section {
1195            for row in rows {
1196                // An incomplete subtree's mtime is a lower bound, and a lower-bound
1197                // maximum is not an age: the activity that would make the directory
1198                // younger may sit in the part that was never listed.
1199                row.age_ns = match row.complete {
1200                    Some(false) => None,
1201                    Some(true) | None => {
1202                        age_reference_ns.map(|now| i128::from(now) - i128::from(row.mtime_ns))
1203                    }
1204                };
1205            }
1206        }
1207    }
1208    let ignore_rules = index.control_coverage();
1209    Ok(Report {
1210        age_reference_ns,
1211        format: query.format,
1212        notes: display_notes(query, &ignore_rules),
1213        status: TreeStatus::of(index, request),
1214        provenance: ReportProvenance::of(index, content, generated_at),
1215        scope: index.scope(),
1216        requested_analysis: content,
1217        requested_views: query.views.clone(),
1218        omitted_views: query.omitted_views.clone(),
1219        root: index.root_path().to_path_buf(),
1220        size: query.selection.size,
1221        analysis: index.content().and_then(|held| {
1222            let wanted = index.content_identity(content);
1223            let projected = held.admit(&wanted)?;
1224            Some(ContentReportMetadata {
1225                profile: projected.identity().analysis,
1226                provenance: projected.identity().record_provenance(),
1227            })
1228        }),
1229        ignored_entries: query.selection.ignored,
1230        ignore_rules,
1231        sections,
1232    })
1233}
1234
1235/// Build a one-section report from an already reduced exact summary.
1236///
1237/// Pure for the same reason as [`report`]: scanning and time sampling happened before
1238/// this boundary.  The execution planner uses this when a one-shot request proves that
1239/// retaining paths and hierarchy cannot affect its answer.
1240pub(crate) fn report_summary(
1241    root: &Path,
1242    scope: ScanScope,
1243    request: &Request,
1244    summary: SummaryRow,
1245    status: TreeStatus,
1246    provenance: ReportProvenance,
1247) -> Report {
1248    Report {
1249        age_reference_ns: crate::query::system_time_to_nanos(request.now),
1250        format: request.query.format,
1251        // A compact summary resolves one view and drops none.
1252        notes: Vec::new(),
1253        status,
1254        provenance,
1255        scope,
1256        requested_analysis: AnalysisSet::NONE,
1257        requested_views: vec![ViewSpec::Summary],
1258        omitted_views: Vec::new(),
1259        root: root.to_path_buf(),
1260        size: request.query.selection.size,
1261        // The planner only selects this tier when no analysis was requested, so there is
1262        // no analyzer provenance to report.
1263        analysis: None,
1264        // An unfiltered summary selects every entry.
1265        ignored_entries: IgnoredEntries::Include,
1266        // Nor when control state is observed, since it retains no table to classify with.
1267        ignore_rules: ControlCoverage::NotObserved,
1268        sections: vec![Section::Summary(SummaryRow { ignored: None, ..summary })],
1269    }
1270}
1271
1272/// Aggregates gathered by one filtered traversal.
1273struct Walked {
1274    /// Whether the walked index observed control state, so its rows carry ignored shares.
1275    observed: bool,
1276    /// Filtered subtree aggregates, keyed by directory id.
1277    ///
1278    /// A row's `ignored` stays `None` until an ignored entry is admitted beneath it;
1279    /// [`Self::summary_of`] is what reads it as the index's observation says.
1280    per_directory: BTreeMap<EntryId, SummaryRow>,
1281    /// Filtered per-extension tallies.
1282    by_ext: BTreeMap<String, ExtTally>,
1283    /// The ignored part of each filtered per-extension tally, for extensions that have one.
1284    ignored_by_ext: BTreeMap<String, ExtTally>,
1285    /// Entries the selection admitted.
1286    rows: Vec<FileRow>,
1287    /// Regular files in the union of matches and selected subtrees, counted once.
1288    members: Vec<FileRow>,
1289    /// Directories in that union or on a path to it, including empty matches.
1290    visible: BTreeSet<EntryId>,
1291}
1292
1293impl Walked {
1294    /// One directory's filtered totals, with an ignored share exactly when observed.
1295    fn summary_of(&self, id: EntryId) -> SummaryRow {
1296        let mut row = self.per_directory.get(&id).copied().unwrap_or_default();
1297        row.ignored = self.observed.then(|| row.ignored.unwrap_or_default());
1298        row
1299    }
1300}
1301
1302/// One directory's unfiltered totals from the roll-up state the index maintains, with its
1303/// ignored share, `all` less `unignored`, when the index observed control state.
1304fn unfiltered_summary(index: &Index, id: EntryId) -> SummaryRow {
1305    let observed = index.observes_controls();
1306    let Some((all, unignored)) = index.partition_scalars_of(id) else {
1307        return SummaryRow {
1308            ignored: observed.then(IgnoredTally::default),
1309            ..SummaryRow::default()
1310        };
1311    };
1312    SummaryRow {
1313        ignored: observed.then(|| IgnoredTally::between(all, unignored)),
1314        ..summary_from_scalars(all)
1315    }
1316}
1317
1318/// Walk the retained index once, aggregating only what the selection admits.
1319///
1320/// Iterative rather than recursive: this engine is built for trees deep enough that a
1321/// recursive post-order would exhaust the stack.
1322fn walk(index: &Index, selection: &Selection, identity: NameIdentity) -> Walked {
1323    let observed = index.observes_controls();
1324    let mut walked = Walked {
1325        observed,
1326        per_directory: BTreeMap::new(),
1327        by_ext: BTreeMap::new(),
1328        ignored_by_ext: BTreeMap::new(),
1329        rows: Vec::new(),
1330        members: Vec::new(),
1331        visible: BTreeSet::new(),
1332    };
1333    // No entry of an index that read no rule can be shown to be ignored or not, so
1334    // `report_in` refuses a selection by ignored state before it reaches this walk.
1335    debug_assert!(
1336        observed || selection.ignored == IgnoredEntries::Include,
1337        "a selection by ignored state over an unobserving index is refused before the walk"
1338    );
1339
1340    // Directory predicates see subtree values even in mixed listings. A file-only
1341    // selection needs no directory measurements and retains its existing query cost.
1342    let directories = (selection.kinds.is_empty() || selection.kinds.contains(&EntryKind::Dir))
1343        .then(|| query_subtrees::measure(index, selection, identity));
1344    // (id, path, post-order, covered by a selected ancestor)
1345    let mut stack = vec![(EntryId::ROOT, PathBuf::new(), false, false)];
1346    while let Some((id, path, expanded, covered)) = stack.pop() {
1347        if expanded {
1348            // Post-order: every child has finished, so fold their totals into this one.
1349            // `total` already carries this directory's own admitted files and admitted
1350            // directory children, both tallied in the pre-order pass below; what is left
1351            // is to add what each child subtree found deeper down.
1352            let mut total = walked.per_directory.remove(&id).unwrap_or_default();
1353            if let Some(children) = index.children_of(id) {
1354                for (_, child) in children {
1355                    if let Some(sub) = walked.per_directory.get(&child) {
1356                        let sub = *sub;
1357                        merge_summary(&mut total, &sub);
1358                    }
1359                }
1360            }
1361            if total.files > 0 || total.dirs > 0 {
1362                walked.visible.insert(id);
1363            }
1364            walked.per_directory.insert(id, total);
1365            continue;
1366        }
1367
1368        stack.push((id, path.clone(), true, covered));
1369        let Some(children) = index.children_of(id) else {
1370            continue;
1371        };
1372        let children: Vec<(PathBuf, EntryId)> =
1373            children.map(|(name, child)| (path.join(name), child)).collect();
1374
1375        for (child_path, child) in children {
1376            let (Some(kind), Some(attrs)) = (index.kind_of(child), index.attrs_of(child)) else {
1377                continue;
1378            };
1379            // Bound once, and as an `OsStr`: the bucket has to be derived from the same
1380            // bytes the index interned from, or a name that is not valid UTF-8 would be
1381            // filed under one label by the fast tier and another by this one.
1382            let file_name = child_path.file_name().unwrap_or_default();
1383            let ignored = index.ignored_bit_of(child).unwrap_or(false);
1384            let mut measured = *attrs;
1385            let subtree = directories.as_ref().and_then(|values| values.get(&child)).copied();
1386            if let Some(subtree) = subtree {
1387                measured.size = subtree.bytes;
1388                measured.allocated = subtree.allocated;
1389                measured.mtime_ns = subtree.mtime_ns;
1390            }
1391            let (pruned, matches) = query_subtrees::with_candidate(
1392                &child_path,
1393                kind,
1394                measured,
1395                ignored,
1396                identity,
1397                |candidate| {
1398                    (query_subtrees::pruned(selection, &candidate), selection.admits(&candidate))
1399                },
1400            );
1401            if pruned {
1402                continue;
1403            }
1404            // An incomplete subtree's newest activity is a lower bound, not an age, so no
1405            // modification bound can be shown to hold for it: `before` could be disproved
1406            // by any unlisted descendant, and `since`, which a lower bound could prove, is
1407            // held to the same rule so that a row's presence under a time filter always
1408            // means the filter was decided on a complete measurement. A size bound still
1409            // matches, since a lower bound at or above the minimum proves the true size is.
1410            let matches = matches
1411                && (selection.modified.is_unbounded()
1412                    || subtree.is_none_or(|subtree| subtree.complete));
1413            let row = FileRow {
1414                path: child_path.clone(),
1415                kind,
1416                bytes: measured.size,
1417                allocated: measured.allocated,
1418                mtime_ns: measured.mtime_ns,
1419                files: subtree.map(|subtree| subtree.files),
1420                dirs: subtree.map(|subtree| subtree.dirs),
1421                complete: subtree.map(|subtree| subtree.complete),
1422                age_ns: None,
1423                ignored: observed.then_some(ignored),
1424            };
1425            if matches {
1426                walked.rows.push(row.clone());
1427            }
1428            if matches || (covered && selection.ignored.admits(ignored)) {
1429                if kind == EntryKind::File {
1430                    walked.members.push(row);
1431                } else if kind == EntryKind::Dir {
1432                    walked.visible.insert(child);
1433                }
1434
1435                if kind == EntryKind::File {
1436                    let own = walked.per_directory.entry(id).or_default();
1437                    own.files += 1;
1438                    own.bytes += attrs.size;
1439                    own.allocated += attrs.allocated;
1440                    own.newest_mtime_ns = Some(
1441                        own.newest_mtime_ns.map_or(attrs.mtime_ns, |seen| seen.max(attrs.mtime_ns)),
1442                    );
1443                    let bucket = crate::classify::ext_bucket(file_name);
1444                    if ignored {
1445                        own.ignored.get_or_insert_with(IgnoredTally::default).add(IgnoredTally {
1446                            files: 1,
1447                            dirs: 0,
1448                            bytes: attrs.size,
1449                            allocated: attrs.allocated,
1450                        });
1451                        let tally = walked.ignored_by_ext.entry(bucket.clone()).or_default();
1452                        tally.files += 1;
1453                        tally.bytes += attrs.size;
1454                        tally.allocated += attrs.allocated;
1455                    }
1456                    let tally = walked.by_ext.entry(bucket).or_default();
1457                    tally.files += 1;
1458                    tally.bytes += attrs.size;
1459                    tally.allocated += attrs.allocated;
1460                } else if kind == EntryKind::Dir {
1461                    // Tallied here, beside the file case, rather than in the post-order
1462                    // fold: the fold sees every directory the walk descended into, and
1463                    // counting there reported directories the selection had rejected.
1464                    // `--kind file` answered "6 files, 3 directories", and a summary
1465                    // disagreed with the files view over the very same query.
1466                    let own = walked.per_directory.entry(id).or_default();
1467                    own.dirs += 1;
1468                    if ignored {
1469                        own.ignored.get_or_insert_with(IgnoredTally::default).dirs += 1;
1470                    }
1471                }
1472            }
1473
1474            if kind == EntryKind::Dir {
1475                stack.push((child, child_path, false, covered || matches));
1476            }
1477        }
1478    }
1479
1480    walked
1481}
1482
1483/// Fold one subtree's filtered totals into another's.
1484fn merge_summary(into: &mut SummaryRow, from: &SummaryRow) {
1485    into.files += from.files;
1486    into.dirs += from.dirs;
1487    into.bytes += from.bytes;
1488    into.allocated += from.allocated;
1489    into.newest_mtime_ns = match (into.newest_mtime_ns, from.newest_mtime_ns) {
1490        (Some(left), Some(right)) => Some(left.max(right)),
1491        (left, right) => left.or(right),
1492    };
1493    if let Some(share) = from.ignored {
1494        into.ignored.get_or_insert_with(IgnoredTally::default).add(share);
1495    }
1496}
1497
1498/// Views that reconstruct every path into a [`FileRow`] when the selection is unfiltered.
1499fn needs_unfiltered_entry_rows(view: ViewSpec) -> bool {
1500    matches!(
1501        view,
1502        ViewSpec::Types
1503            | ViewSpec::Families
1504            | ViewSpec::Languages
1505            | ViewSpec::Documents
1506            | ViewSpec::Files
1507            | ViewSpec::Largest
1508            | ViewSpec::Recent
1509    )
1510}
1511
1512/// The entry rows a view aggregates: the filtered walk, a shared unfiltered walk, or a
1513/// fresh [`every_entry`] when this is the only consumer.
1514fn entry_rows<'a>(
1515    index: &Index,
1516    walked: Option<&'a Walked>,
1517    unfiltered_rows: Option<&'a [FileRow]>,
1518) -> Cow<'a, [FileRow]> {
1519    match (walked, unfiltered_rows) {
1520        (Some(walked), _) => Cow::Borrowed(&walked.rows),
1521        (None, Some(rows)) => Cow::Borrowed(rows),
1522        (None, None) => Cow::Owned(every_entry(index)),
1523    }
1524}
1525
1526/// Build one view's section, using the pre-computed tier when the selection allows.
1527fn build_section(
1528    view: ViewSpec,
1529    index: &Index,
1530    query: &Query,
1531    content: AnalysisSet,
1532    walked: Option<&Walked>,
1533    unfiltered_rows: Option<&[FileRow]>,
1534) -> Section {
1535    if query.tree_for(view) {
1536        return Section::Tree { view, root: tree_node(index, query, walked) };
1537    }
1538    match view {
1539        ViewSpec::Summary => Section::Summary(match walked {
1540            None => unfiltered_summary(index, EntryId::ROOT),
1541            Some(walked) => walked.summary_of(EntryId::ROOT),
1542        }),
1543        ViewSpec::Extensions => {
1544            let (rows, total) = extension_rows(index, query, walked);
1545            Section::Extensions { rows, total }
1546        }
1547        ViewSpec::Types | ViewSpec::Families | ViewSpec::Languages | ViewSpec::Documents => {
1548            Section::Metrics {
1549                view,
1550                summary: Box::new(metric_summary(
1551                    view,
1552                    index,
1553                    query,
1554                    content,
1555                    walked,
1556                    unfiltered_rows,
1557                )),
1558            }
1559        }
1560        ViewSpec::List
1561        | ViewSpec::Tree
1562        | ViewSpec::Files
1563        | ViewSpec::Largest
1564        | ViewSpec::Recent => {
1565            let (rows, total) = file_rows(view, index, query, walked, unfiltered_rows);
1566            Section::Files { view, rows, total }
1567        }
1568    }
1569}
1570
1571/// A summary row taken straight from pre-computed roll-up state, before any ignored share.
1572fn summary_from_scalars(rollup: RollUpScalars) -> SummaryRow {
1573    SummaryRow {
1574        files: rollup.files,
1575        dirs: rollup.dirs,
1576        bytes: rollup.bytes,
1577        allocated: rollup.allocated,
1578        ignored: None,
1579        newest_mtime_ns: (rollup.files > 0).then_some(rollup.newest_mtime_ns),
1580    }
1581}
1582
1583/// What each extension tally in `all` holds beyond the same extension in `unignored`.
1584fn ignored_by_extension(
1585    all: &BTreeMap<String, ExtTally>,
1586    unignored: &BTreeMap<String, ExtTally>,
1587) -> BTreeMap<String, ExtTally> {
1588    all.iter()
1589        .filter_map(|(extension, tally)| {
1590            let kept = unignored.get(extension).copied().unwrap_or_default();
1591            let ignored = ExtTally {
1592                files: tally.files.saturating_sub(kept.files),
1593                bytes: tally.bytes.saturating_sub(kept.bytes),
1594                allocated: tally.allocated.saturating_sub(kept.allocated),
1595            };
1596            (ignored.files > 0).then(|| (extension.clone(), ignored))
1597        })
1598        .collect()
1599}
1600
1601/// Rows for the types view.
1602fn extension_rows(index: &Index, query: &Query, walked: Option<&Walked>) -> (Vec<TypeRow>, usize) {
1603    let observed = index.observes_controls();
1604    let (tallies, ignored): (BTreeMap<String, ExtTally>, BTreeMap<String, ExtTally>) = match walked
1605    {
1606        None => match index.partition_total() {
1607            Ok(partitions) => {
1608                let ignored =
1609                    ignored_by_extension(&partitions.all.by_ext, &partitions.unignored.by_ext);
1610                (partitions.all.by_ext, ignored)
1611            }
1612            // An index that observed no control state has no unignored partition to
1613            // subtract, and its rows carry no ignored share.
1614            Err(_not_observed) => (index.total().by_ext, BTreeMap::new()),
1615        },
1616        Some(walked) => (walked.by_ext.clone(), walked.ignored_by_ext.clone()),
1617    };
1618
1619    let mut rows: Vec<TypeRow> = tallies
1620        .into_iter()
1621        .map(|(extension, tally)| {
1622            let share = ignored.get(&extension).copied().unwrap_or_default();
1623            TypeRow {
1624                files: tally.files,
1625                bytes: tally.bytes,
1626                allocated: tally.allocated,
1627                ignored: observed.then_some(IgnoredTally {
1628                    files: share.files,
1629                    dirs: 0,
1630                    bytes: share.bytes,
1631                    allocated: share.allocated,
1632                }),
1633                extension,
1634            }
1635        })
1636        .collect();
1637
1638    sort_rows(
1639        &mut rows,
1640        query,
1641        ViewSpec::Extensions,
1642        |row, metric| match metric {
1643            SizeMetric::Apparent => row.bytes,
1644            SizeMetric::Allocated => row.allocated,
1645        },
1646        |row| row.files,
1647        |_| None,
1648        |row| row.extension.clone(),
1649    );
1650    let total = truncate(&mut rows, query.limit_for(ViewSpec::Extensions));
1651    (rows, total)
1652}
1653
1654fn metric_summary(
1655    view: ViewSpec,
1656    index: &Index,
1657    query: &Query,
1658    content: AnalysisSet,
1659    walked: Option<&Walked>,
1660    unfiltered_rows: Option<&[FileRow]>,
1661) -> MetricSummary {
1662    let group = if view == ViewSpec::Families { MetricGroup::Family } else { MetricGroup::Type };
1663    let files = walked.map_or_else(
1664        || entry_rows(index, None, unfiltered_rows),
1665        |walked| Cow::Borrowed(walked.members.as_slice()),
1666    );
1667    let mut grouped = BTreeMap::<String, MetricRow>::new();
1668    let wanted = index.content_identity(content);
1669    let held = index.content().and_then(|held| held.admit(&wanted));
1670    for file in files.iter().filter(|row| row.kind == EntryKind::File) {
1671        let cached = held.and_then(|content| content.file(&file.path));
1672        let classification = index.classify(&file.path);
1673        let included = match view {
1674            ViewSpec::Languages => classification.family == ContentFamily::Code,
1675            ViewSpec::Documents => {
1676                matches!(classification.family, ContentFamily::Prose | ContentFamily::Markup)
1677            }
1678            ViewSpec::Types | ViewSpec::Families => true,
1679            ViewSpec::List
1680            | ViewSpec::Tree
1681            | ViewSpec::Extensions
1682            | ViewSpec::Files
1683            | ViewSpec::Largest
1684            | ViewSpec::Recent
1685            | ViewSpec::Summary => false,
1686        };
1687        if !included {
1688            continue;
1689        }
1690        let id = match group {
1691            MetricGroup::Type => classification.file_type.as_str().to_string(),
1692            MetricGroup::Family => classification.family.as_str().to_string(),
1693        };
1694        let row = grouped.entry(id.clone()).or_insert_with(|| MetricRow {
1695            analysis: content,
1696            id,
1697            family: classification.family,
1698            files: 0,
1699            bytes: 0,
1700            allocated: 0,
1701            analyzed_files: 0,
1702            metrics: ReportMetricValues::for_analysis(content),
1703            logical_word_stats: LogicalWordStats::default(),
1704            visible_logical_word_stats: LogicalWordStats::default(),
1705            document_raw_words: 0,
1706            document_word_stats: LogicalWordStats::default(),
1707            document_metric_files: 0,
1708            coverage: BTreeMap::new(),
1709            lines_coverage: BTreeMap::new(),
1710            code_coverage: content.includes_code().then(BTreeMap::new),
1711            words_coverage: content.includes_words().then(BTreeMap::new),
1712            detection_sources: BTreeMap::new(),
1713            detection_confidence: BTreeMap::new(),
1714            generated_files: 0,
1715            vendored_files: 0,
1716            documentation_files: 0,
1717            share: MetricShare::default(),
1718        });
1719        row.files = row.files.saturating_add(1);
1720        row.bytes = row.bytes.saturating_add(file.bytes);
1721        row.allocated = row.allocated.saturating_add(file.allocated);
1722        let detection = cached.map_or(
1723            (classification.source, classification.confidence, classification.flags),
1724            |record| (record.detection.source, record.detection.confidence, record.detection.flags),
1725        );
1726        *row.detection_sources.entry(detection.0).or_default() += 1;
1727        *row.detection_confidence.entry(detection.1).or_default() += 1;
1728        row.generated_files = row.generated_files.saturating_add(u64::from(detection.2.generated));
1729        row.vendored_files = row.vendored_files.saturating_add(u64::from(detection.2.vendored));
1730        row.documentation_files =
1731            row.documentation_files.saturating_add(u64::from(detection.2.documentation));
1732        if let Some(record) = cached {
1733            *row.lines_coverage.entry(record.lines.coverage()).or_default() += 1;
1734            if let (Some(coverage), Some(outcome)) = (&mut row.code_coverage, record.code) {
1735                *coverage.entry(outcome.coverage()).or_default() += 1;
1736            }
1737            if let (Some(coverage), Some(outcome)) = (&mut row.words_coverage, record.words) {
1738                *coverage.entry(outcome.coverage()).or_default() += 1;
1739            }
1740            let selected = match view {
1741                ViewSpec::Languages if content.includes_code() => {
1742                    record.code.map(|outcome| outcome.coverage())
1743                }
1744                ViewSpec::Documents if content.includes_words() => {
1745                    record.words.map(|outcome| outcome.coverage())
1746                }
1747                _ => None,
1748            }
1749            .unwrap_or(record.lines.coverage());
1750            *row.coverage.entry(selected).or_default() += 1;
1751            if selected == CoverageReason::Analyzed {
1752                row.analyzed_files = row.analyzed_files.saturating_add(1);
1753            }
1754            if let Some(lines) = record.lines.value() {
1755                add_optional(&mut row.metrics.physical_lines, Some(lines.physical_lines));
1756                add_optional(&mut row.metrics.blank_lines, Some(lines.blank_lines));
1757                add_optional(&mut row.metrics.nonblank_lines, Some(lines.nonblank_lines));
1758                add_optional(&mut row.metrics.raw_words, Some(lines.raw_words));
1759                row.document_raw_words = row.document_raw_words.saturating_add(lines.raw_words);
1760            }
1761            if let Some(code_metrics) = record.code.and_then(crate::content::AnalyzerOutcome::value)
1762            {
1763                add_optional(&mut row.metrics.code_lines, Some(code_metrics.code_lines));
1764                add_optional(&mut row.metrics.comment_lines, Some(code_metrics.comment_lines));
1765                add_optional(
1766                    &mut row.metrics.code_blank_lines,
1767                    Some(code_metrics.code_blank_lines),
1768                );
1769            }
1770            if let Some(words) = record.words.and_then(crate::content::AnalyzerOutcome::value) {
1771                add_optional(&mut row.metrics.paragraphs, Some(words.paragraphs));
1772                add_optional(&mut row.metrics.visible_words, Some(words.visible_words));
1773                row.logical_word_stats.add_assign(words.logical_word_stats);
1774                row.visible_logical_word_stats.add_assign(words.visible_logical_word_stats);
1775                row.document_metric_files = row.document_metric_files.saturating_add(1);
1776                if classification.file_type.as_str() == "markdown" {
1777                    row.document_raw_words = row
1778                        .document_raw_words
1779                        .saturating_sub(record.lines.value().map_or(0, |lines| lines.raw_words));
1780                    row.document_raw_words =
1781                        row.document_raw_words.saturating_add(words.visible_words);
1782                    row.document_word_stats.add_assign(words.visible_logical_word_stats);
1783                } else {
1784                    row.document_word_stats.add_assign(words.logical_word_stats);
1785                }
1786            }
1787        }
1788    }
1789
1790    for row in grouped.values_mut() {
1791        row.finish_derived_metrics();
1792    }
1793
1794    let mut total = MetricRow {
1795        analysis: content,
1796        id: "total".to_string(),
1797        family: ContentFamily::Unknown,
1798        files: 0,
1799        bytes: 0,
1800        allocated: 0,
1801        analyzed_files: 0,
1802        metrics: ReportMetricValues::for_analysis(content),
1803        logical_word_stats: LogicalWordStats::default(),
1804        visible_logical_word_stats: LogicalWordStats::default(),
1805        document_raw_words: 0,
1806        document_word_stats: LogicalWordStats::default(),
1807        document_metric_files: 0,
1808        coverage: BTreeMap::new(),
1809        lines_coverage: BTreeMap::new(),
1810        code_coverage: content.includes_code().then(BTreeMap::new),
1811        words_coverage: content.includes_words().then(BTreeMap::new),
1812        detection_sources: BTreeMap::new(),
1813        detection_confidence: BTreeMap::new(),
1814        generated_files: 0,
1815        vendored_files: 0,
1816        documentation_files: 0,
1817        share: MetricShare::default(),
1818    };
1819    for row in grouped.values() {
1820        total.files = total.files.saturating_add(row.files);
1821        total.bytes = total.bytes.saturating_add(row.bytes);
1822        total.allocated = total.allocated.saturating_add(row.allocated);
1823        total.analyzed_files = total.analyzed_files.saturating_add(row.analyzed_files);
1824        total.metrics.add_assign(&row.metrics);
1825        total.logical_word_stats.add_assign(row.logical_word_stats);
1826        total.visible_logical_word_stats.add_assign(row.visible_logical_word_stats);
1827        total.document_raw_words = total.document_raw_words.saturating_add(row.document_raw_words);
1828        total.document_word_stats.add_assign(row.document_word_stats);
1829        total.document_metric_files =
1830            total.document_metric_files.saturating_add(row.document_metric_files);
1831        for (reason, count) in &row.coverage {
1832            *total.coverage.entry(*reason).or_default() += count;
1833        }
1834        merge_coverage(&mut total.lines_coverage, &row.lines_coverage);
1835        if let (Some(total), Some(row)) = (&mut total.code_coverage, &row.code_coverage) {
1836            merge_coverage(total, row);
1837        }
1838        if let (Some(total), Some(row)) = (&mut total.words_coverage, &row.words_coverage) {
1839            merge_coverage(total, row);
1840        }
1841        for (source, count) in &row.detection_sources {
1842            *total.detection_sources.entry(*source).or_default() += count;
1843        }
1844        for (confidence, count) in &row.detection_confidence {
1845            *total.detection_confidence.entry(*confidence).or_default() += count;
1846        }
1847        total.generated_files = total.generated_files.saturating_add(row.generated_files);
1848        total.vendored_files = total.vendored_files.saturating_add(row.vendored_files);
1849        total.documentation_files =
1850            total.documentation_files.saturating_add(row.documentation_files);
1851    }
1852    total.finish_derived_metrics();
1853    let byte_share_metric = match query.selection.size {
1854        SizeMetric::Apparent => ShareMetric::ApparentBytes,
1855        SizeMetric::Allocated => ShareMetric::AllocatedBytes,
1856    };
1857    let share_metric = match view {
1858        // The requested analyzers, not the stored ones: a share is a fact about what the
1859        // request asked to measure, and `validate_read` proved the index holds exactly it.
1860        ViewSpec::Languages if content.includes_code() => ShareMetric::CodeLines,
1861        ViewSpec::Documents if content.includes_words() => ShareMetric::DocumentWords,
1862        ViewSpec::Languages | ViewSpec::Documents if content.is_enabled() => ShareMetric::RawWords,
1863        ViewSpec::Languages | ViewSpec::Documents | ViewSpec::Types | ViewSpec::Families => {
1864            byte_share_metric
1865        }
1866        ViewSpec::List
1867        | ViewSpec::Tree
1868        | ViewSpec::Extensions
1869        | ViewSpec::Files
1870        | ViewSpec::Largest
1871        | ViewSpec::Recent
1872        | ViewSpec::Summary => {
1873            unreachable!("only grouped views reach metric_summary")
1874        }
1875    };
1876    let denominator = share_value(&total, share_metric);
1877    total.share = MetricShare { numerator: denominator, denominator };
1878    let mut rows = grouped.into_values().collect::<Vec<_>>();
1879    for row in &mut rows {
1880        row.share = MetricShare { numerator: share_value(row, share_metric), denominator };
1881    }
1882    sort_rows(
1883        &mut rows,
1884        query,
1885        view,
1886        |row, metric| match view {
1887            ViewSpec::Languages | ViewSpec::Documents => share_value(row, share_metric),
1888            _ => match metric {
1889                SizeMetric::Apparent => row.bytes,
1890                SizeMetric::Allocated => row.allocated,
1891            },
1892        },
1893        |row| row.files,
1894        |_| None,
1895        |row| row.id.clone(),
1896    );
1897    let total_rows = truncate(&mut rows, query.limit_for(view));
1898    MetricSummary {
1899        group,
1900        total,
1901        rows,
1902        total_rows,
1903        share_metric,
1904        words_per_page: query.words_per_page.max(1),
1905    }
1906}
1907
1908fn merge_coverage(total: &mut BTreeMap<CoverageReason, u64>, row: &BTreeMap<CoverageReason, u64>) {
1909    for (reason, count) in row {
1910        *total.entry(*reason).or_default() += count;
1911    }
1912}
1913
1914fn share_value(row: &MetricRow, metric: ShareMetric) -> u64 {
1915    match metric {
1916        ShareMetric::ApparentBytes => row.bytes,
1917        ShareMetric::AllocatedBytes => row.allocated,
1918        ShareMetric::CodeLines => row.metrics.code_lines.unwrap_or(0),
1919        ShareMetric::DocumentWords => document_words(row).unwrap_or(0),
1920        ShareMetric::RawWords => row.metrics.raw_words.unwrap_or(0),
1921    }
1922}
1923
1924/// Derive the selected document volume only after every sufficient statistic is added.
1925pub fn document_words(row: &MetricRow) -> Option<u64> {
1926    row.metrics.document_words
1927}
1928
1929/// Derived page inputs, absent unless the words unit was requested.
1930#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1931pub struct Pages {
1932    /// Query-selected document words.
1933    pub words: u64,
1934    /// Words represented by one page.
1935    pub words_per_page: u64,
1936}
1937
1938/// Build page inputs only when document words were measured.
1939pub fn pages(row: &MetricRow, words_per_page: u64) -> Option<Pages> {
1940    document_words(row).map(|words| Pages { words, words_per_page: words_per_page.max(1) })
1941}
1942
1943/// Rows for the files view.
1944fn file_rows(
1945    view: ViewSpec,
1946    index: &Index,
1947    query: &Query,
1948    walked: Option<&Walked>,
1949    unfiltered_rows: Option<&[FileRow]>,
1950) -> (Vec<FileRow>, usize) {
1951    let mut rows = entry_rows(index, walked, unfiltered_rows).into_owned();
1952    if view.files_only() {
1953        rows.retain(|row| row.kind == EntryKind::File);
1954    }
1955
1956    sort_rows(
1957        &mut rows,
1958        query,
1959        view,
1960        |row, metric| match metric {
1961            SizeMetric::Apparent => row.bytes,
1962            SizeMetric::Allocated => row.allocated,
1963        },
1964        |row| row.files.unwrap_or(1),
1965        |row| Some(row.mtime_ns),
1966        |row| row.path.to_string_lossy().into_owned(),
1967    );
1968    let total = truncate(&mut rows, query.limit_for(view));
1969    (rows, total)
1970}
1971
1972/// Every entry in the index, for an unfiltered files view.
1973fn every_entry(index: &Index) -> Vec<FileRow> {
1974    let observed = index.observes_controls();
1975    let mut rows = Vec::new();
1976    let mut stack: Vec<(EntryId, PathBuf)> = vec![(EntryId::ROOT, PathBuf::new())];
1977    while let Some((id, path)) = stack.pop() {
1978        let Some(children) = index.children_of(id) else {
1979            continue;
1980        };
1981        let children: Vec<(PathBuf, EntryId)> =
1982            children.map(|(name, child)| (path.join(name), child)).collect();
1983        for (child_path, child) in children {
1984            let (Some(kind), Some(attrs)) = (index.kind_of(child), index.attrs_of(child)) else {
1985                continue;
1986            };
1987            rows.push(FileRow {
1988                path: child_path.clone(),
1989                kind,
1990                bytes: attrs.size,
1991                allocated: attrs.allocated,
1992                mtime_ns: attrs.mtime_ns,
1993                files: None,
1994                dirs: None,
1995                complete: None,
1996                age_ns: None,
1997                ignored: observed.then(|| index.ignored_bit_of(child).unwrap_or(false)),
1998            });
1999            if kind == EntryKind::Dir {
2000                stack.push((child, child_path));
2001            }
2002        }
2003    }
2004    rows
2005}
2006
2007/// The tree view's root node, expanded to the requested depth.
2008fn tree_node(index: &Index, query: &Query, walked: Option<&Walked>) -> TreeNode {
2009    let root_summary = match walked {
2010        None => unfiltered_summary(index, EntryId::ROOT),
2011        Some(walked) => walked.summary_of(EntryId::ROOT),
2012    };
2013
2014    let mut root = TreeNode {
2015        path: PathBuf::new(),
2016        name: ".".to_string(),
2017        kind: EntryKind::Dir,
2018        bytes: root_summary.bytes,
2019        allocated: root_summary.allocated,
2020        files: root_summary.files,
2021        dirs: root_summary.dirs,
2022        ignored: root_summary.ignored,
2023        newest_mtime_ns: root_summary.newest_mtime_ns,
2024        children: Vec::new(),
2025        truncated: false,
2026    };
2027    expand(index, query, walked, EntryId::ROOT, &PathBuf::new(), &mut root, 0);
2028    root
2029}
2030
2031/// Attach a node's children, honoring the depth and per-directory limit bounds.
2032///
2033/// Iterative rather than recursive: this engine indexes trees deep enough that recursive
2034/// expansion would exhaust the stack, and a report that panics on a deep tree fails
2035/// exactly where the tool is most useful. Nodes are built flat with parent links in
2036/// pre-order, then folded together from the leaves up.
2037fn expand(
2038    index: &Index,
2039    query: &Query,
2040    walked: Option<&Walked>,
2041    root_id: EntryId,
2042    root_path: &Path,
2043    node: &mut TreeNode,
2044    start_depth: usize,
2045) {
2046    /// One node awaiting its children.
2047    struct Pending {
2048        node: TreeNode,
2049        id: EntryId,
2050        depth: usize,
2051        parent: Option<usize>,
2052    }
2053
2054    let mut built = vec![Pending {
2055        // Only identity and bounds matter while expanding; the caller keeps the
2056        // populated root and receives its children back at the end.
2057        node: TreeNode {
2058            path: root_path.to_path_buf(),
2059            name: node.name.clone(),
2060            kind: node.kind,
2061            bytes: node.bytes,
2062            allocated: node.allocated,
2063            files: node.files,
2064            dirs: node.dirs,
2065            ignored: node.ignored,
2066            newest_mtime_ns: node.newest_mtime_ns,
2067            children: Vec::new(),
2068            truncated: false,
2069        },
2070        id: root_id,
2071        depth: start_depth,
2072        parent: None,
2073    }];
2074
2075    let mut cursor = 0;
2076    while cursor < built.len() {
2077        let (id, depth) = (built[cursor].id, built[cursor].depth);
2078        let path = built[cursor].node.path.clone();
2079
2080        if !query.selection.depth.unwrap_or(ViewSpec::Tree.default_depth()).admits(depth) {
2081            // `--depth 0` keeps du's meaning: totals for this node, nothing beneath it.
2082            // Files are already represented in this directory's totals and never become
2083            // tree rows. Only a directory child hidden by the depth bound makes the
2084            // rendered hierarchy incomplete.
2085            built[cursor].node.truncated = index.children_of(id).is_some_and(|mut children| {
2086                children.any(|(_, child)| {
2087                    index.kind_of(child) == Some(EntryKind::Dir)
2088                        && walked.is_none_or(|walked| walked.visible.contains(&child))
2089                })
2090            });
2091            cursor += 1;
2092            continue;
2093        }
2094
2095        let mut rows = child_rows(index, query, walked, id, &path);
2096        let kept = query
2097            .selection
2098            .limit
2099            .unwrap_or(ViewSpec::Tree.default_limit())
2100            .limit()
2101            .unwrap_or(rows.len())
2102            .min(rows.len());
2103        built[cursor].node.truncated = kept < rows.len();
2104        rows.truncate(kept);
2105
2106        for (child_node, child_id) in rows {
2107            built.push(Pending {
2108                node: child_node,
2109                id: child_id,
2110                depth: depth + 1,
2111                parent: Some(cursor),
2112            });
2113        }
2114        cursor += 1;
2115    }
2116
2117    // Fold from the end: every parent index is smaller than its child's, so removing the
2118    // last element never disturbs an index still to be used.
2119    for position in (1..built.len()).rev() {
2120        let child = built.remove(position);
2121        let parent = child.parent.expect("only the root has no parent");
2122        built[parent].node.children.insert(0, child.node);
2123    }
2124
2125    let mut root = built.pop().expect("the root is always present");
2126    node.children = std::mem::take(&mut root.node.children);
2127    node.truncated = root.node.truncated;
2128}
2129
2130/// The directory children of one node, shaped and sorted but not yet expanded.
2131fn child_rows(
2132    index: &Index,
2133    query: &Query,
2134    walked: Option<&Walked>,
2135    id: EntryId,
2136    path: &Path,
2137) -> Vec<(TreeNode, EntryId)> {
2138    let Some(children) = index.children_of(id) else {
2139        return Vec::new();
2140    };
2141    let children: Vec<(PathBuf, EntryId)> =
2142        children.map(|(name, child)| (path.join(name), child)).collect();
2143
2144    let mut rows: Vec<(TreeNode, EntryId)> = Vec::new();
2145    for (child_path, child) in children {
2146        let Some(kind) = index.kind_of(child) else {
2147            continue;
2148        };
2149        // The tree view is a directory hierarchy: a file contributes its bytes to the
2150        // directory holding it rather than appearing as its own row.
2151        if kind != EntryKind::Dir {
2152            continue;
2153        }
2154        if walked.is_some_and(|walked| !walked.visible.contains(&child)) {
2155            continue;
2156        }
2157        let summary = match walked {
2158            None => unfiltered_summary(index, child),
2159            Some(walked) => walked.summary_of(child),
2160        };
2161        let name = child_path
2162            .file_name()
2163            .map(|name| name.to_string_lossy().into_owned())
2164            .unwrap_or_default();
2165        rows.push((
2166            TreeNode {
2167                path: child_path,
2168                name,
2169                kind,
2170                bytes: summary.bytes,
2171                allocated: summary.allocated,
2172                files: summary.files,
2173                dirs: summary.dirs,
2174                ignored: summary.ignored,
2175                newest_mtime_ns: summary.newest_mtime_ns,
2176                children: Vec::new(),
2177                truncated: false,
2178            },
2179            child,
2180        ));
2181    }
2182
2183    sort_rows_by(
2184        &mut rows,
2185        query,
2186        ViewSpec::Tree,
2187        |(row, _), metric| match metric {
2188            SizeMetric::Apparent => row.bytes,
2189            SizeMetric::Allocated => row.allocated,
2190        },
2191        |(row, _)| row.files,
2192        |(row, _)| row.newest_mtime_ns,
2193        |(row, _)| row.name.clone(),
2194    );
2195    rows
2196}
2197
2198/// Trim a row list to the configured limit.
2199fn truncate<T>(rows: &mut Vec<T>, limit: Bound) -> usize {
2200    let total = rows.len();
2201    if let Some(limit) = limit.limit() {
2202        rows.truncate(limit);
2203    }
2204    total
2205}
2206
2207/// Sort rows by the effective key for a view.
2208fn sort_rows<T>(
2209    rows: &mut [T],
2210    query: &Query,
2211    view: ViewSpec,
2212    size: impl Fn(&T, SizeMetric) -> u64,
2213    count: impl Fn(&T) -> u64,
2214    mtime: impl Fn(&T) -> Option<i64>,
2215    name: impl Fn(&T) -> String,
2216) {
2217    sort_rows_by(rows, query, view, size, count, mtime, name);
2218}
2219
2220/// Sort rows by the effective key, with a stable name tiebreak.
2221fn sort_rows_by<T>(
2222    rows: &mut [T],
2223    query: &Query,
2224    view: ViewSpec,
2225    size: impl Fn(&T, SizeMetric) -> u64,
2226    count: impl Fn(&T) -> u64,
2227    mtime: impl Fn(&T) -> Option<i64>,
2228    name: impl Fn(&T) -> String,
2229) {
2230    let key = query.selection.sort.unwrap_or_else(|| view.default_sort());
2231    let metric = query.selection.size;
2232
2233    rows.sort_by(|left, right| {
2234        let ordering = match key {
2235            // Size, count, and recency read most-first: the interesting end is the top.
2236            SortKey::Size => size(right, metric).cmp(&size(left, metric)),
2237            SortKey::Count => count(right).cmp(&count(left)),
2238            SortKey::Mtime => mtime(right).cmp(&mtime(left)),
2239            SortKey::Name => name(left).cmp(&name(right)),
2240        };
2241        // A name tiebreak keeps equal rows in a deterministic order, which is what makes
2242        // the goldens stable across runs and platforms.
2243        ordering.then_with(|| name(left).cmp(&name(right)))
2244    });
2245
2246    if query.selection.reverse {
2247        rows.reverse();
2248    }
2249}
2250
2251#[cfg(test)]
2252mod tests {
2253    use super::*;
2254    use crate::engine_contract::{Attrs, Observation, Op};
2255    use crate::query::query_glob::Pattern;
2256    use crate::query::query_selection::ModifiedWindow;
2257    use std::fs;
2258    use std::time::{Duration, UNIX_EPOCH};
2259
2260    fn attrs(size: u64, mtime_ns: i64) -> Attrs {
2261        Attrs {
2262            size,
2263            allocated: size.div_ceil(512) * 512,
2264            mtime_ns,
2265            ctime_ns: mtime_ns,
2266            inode: size.wrapping_mul(31).wrapping_add(mtime_ns.unsigned_abs()),
2267            dev: 1,
2268        }
2269    }
2270
2271    fn upsert(path: &str, kind: EntryKind, attrs: Attrs) -> Op {
2272        Op::Upsert { path: PathBuf::from(path), kind, attrs }
2273    }
2274
2275    /// A tree with two top-level directories, a nested level, and three extensions.
2276    fn sample() -> Index {
2277        let mut index = Index::new("/root");
2278        index
2279            .apply(&Observation::new(vec![
2280                upsert("src", EntryKind::Dir, Attrs::default()),
2281                upsert("src/main.rs", EntryKind::File, attrs(100, 10)),
2282                upsert("src/lib.rs", EntryKind::File, attrs(200, 20)),
2283                upsert("src/deep", EntryKind::Dir, Attrs::default()),
2284                upsert("src/deep/nested.rs", EntryKind::File, attrs(50, 40)),
2285                upsert("docs", EntryKind::Dir, Attrs::default()),
2286                upsert("docs/guide.md", EntryKind::File, attrs(300, 30)),
2287                upsert("notes.txt", EntryKind::File, attrs(7, 5)),
2288            ]))
2289            .expect("apply");
2290        index
2291    }
2292
2293    #[test]
2294    fn ages_use_one_signed_reference_and_unrepresentable_clocks_are_unknown() {
2295        let mut index = sample();
2296        index.apply_ok(&Observation::new(vec![
2297            upsert("past", EntryKind::File, attrs(1, -10)),
2298            upsert("future", EntryKind::File, attrs(1, i64::MAX)),
2299        ]));
2300        let query = query(
2301            &[ViewSpec::Files],
2302            Selection { include: vec![pattern("past"), pattern("future")], ..Selection::default() },
2303        );
2304        let mut request = Request::new(Basis::held_by(&index), query, UNIX_EPOCH);
2305        let answer = report(&index, &request, UNIX_EPOCH).expect("report");
2306        let rows = files_of(&answer);
2307        assert_eq!(answer.age_reference_ns, Some(0));
2308        assert_eq!(
2309            rows.iter().map(|r| r.age_ns).collect::<Vec<_>>(),
2310            [Some(-i128::from(i64::MAX)), Some(10)]
2311        );
2312        request.now = UNIX_EPOCH + Duration::from_secs(10_000_000_000);
2313        let answer = report(&index, &request, UNIX_EPOCH)
2314            .expect("out-of-range reference is representable as unknown age");
2315        assert_eq!(answer.age_reference_ns, None);
2316        assert!(files_of(&answer).iter().all(|row| row.age_ns.is_none()));
2317    }
2318
2319    #[test]
2320    fn matching_a_directory_selects_its_subtree_once() {
2321        let index = sample();
2322        let selection = Selection {
2323            include: vec![pattern("src"), pattern("deep")],
2324            kinds: vec![EntryKind::Dir],
2325            size: SizeMetric::Apparent,
2326            min_size: Some(40),
2327            ..Selection::default()
2328        };
2329        let report = run(
2330            &index,
2331            &query(&[ViewSpec::Files, ViewSpec::Summary, ViewSpec::Extensions], selection),
2332        );
2333        let rows = files_of(&report);
2334        assert_eq!(rows.len(), 2);
2335        assert_eq!((rows[0].bytes, rows[0].mtime_ns), (350, 40));
2336        let Section::Summary(summary) = &report.sections[1] else { panic!("summary") };
2337        assert_eq!((summary.files, summary.dirs, summary.bytes), (3, 2, 350));
2338        let Section::Extensions { rows, .. } = &report.sections[2] else { panic!("extensions") };
2339        assert_eq!((rows[0].files, rows[0].bytes), (3, 350));
2340    }
2341
2342    #[test]
2343    fn subtree_predicates_include_directory_and_symlink_activity_but_only_file_bytes() {
2344        let mut index = sample();
2345        index
2346            .apply(&Observation::new(vec![
2347                upsert("src", EntryKind::Dir, attrs(9999, 45)),
2348                upsert("src/empty", EntryKind::Dir, attrs(8888, 60)),
2349                upsert("src/link", EntryKind::Symlink, attrs(7777, 70)),
2350                upsert("empty", EntryKind::Dir, attrs(6666, -10)),
2351            ]))
2352            .expect("apply");
2353        let base = Selection {
2354            include: vec![pattern("src"), pattern("empty")],
2355            size: SizeMetric::Apparent,
2356            ..Selection::default()
2357        };
2358        // A one-shot report matches native names, so the nested spelling is joined rather
2359        // than written with a literal separator: `src/empty` holds only on Unix.
2360        let nested = PathBuf::from("src").join("empty").to_string_lossy().into_owned();
2361        let rows = files_of(&run(&index, &query(&[ViewSpec::Files], base.clone())));
2362        assert_eq!(
2363            rows.iter()
2364                .map(|r| (r.path.to_string_lossy().into_owned(), r.bytes, r.mtime_ns))
2365                .collect::<Vec<_>>(),
2366            [("empty".into(), 0, -10), ("src".into(), 350, 70), (nested.clone(), 0, 60)]
2367        );
2368        for (before, since, expected) in [
2369            (70, 0, vec![nested.clone()]),
2370            (71, 70, vec!["src".to_owned()]),
2371            (0, -10, vec!["empty".to_owned()]),
2372        ] {
2373            let selection = Selection {
2374                modified: ModifiedWindow { before: Some(before), since: Some(since) },
2375                ..base.clone()
2376            };
2377            let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
2378            assert_eq!(
2379                rows.iter().map(|r| r.path.to_string_lossy().into_owned()).collect::<Vec<_>>(),
2380                expected
2381            );
2382        }
2383        for (size, minimum, expected) in [
2384            (SizeMetric::Apparent, 350, 1),
2385            (SizeMetric::Apparent, 351, 0),
2386            (SizeMetric::Allocated, 1536, 1),
2387            (SizeMetric::Allocated, 1537, 0),
2388        ] {
2389            let selection = Selection { size, min_size: Some(minimum), ..base.clone() };
2390            assert_eq!(
2391                files_of(&run(&index, &query(&[ViewSpec::Files], selection))).len(),
2392                expected
2393            );
2394        }
2395    }
2396
2397    #[test]
2398    fn exclusions_apply_before_subtree_bounds_and_selected_ancestor_coverage() {
2399        let index = classified_sample();
2400        for (ignored, exclude, bytes, newest) in [
2401            (IgnoredEntries::Include, vec![], 325, 70),
2402            (IgnoredEntries::Exclude, vec![], 300, 20),
2403            (IgnoredEntries::Include, vec![pattern("*.log")], 300, 20),
2404            (IgnoredEntries::Include, vec![pattern("lib.rs")], 125, 70),
2405        ] {
2406            let selection = Selection {
2407                include: vec![pattern("src")],
2408                kinds: vec![EntryKind::Dir],
2409                ignored,
2410                exclude,
2411                size: SizeMetric::Apparent,
2412                ..Selection::default()
2413            };
2414            let report = run(
2415                &index,
2416                &query(&[ViewSpec::Files, ViewSpec::Summary, ViewSpec::Types], selection),
2417            );
2418            let row = &files_of(&report)[0];
2419            assert_eq!((row.bytes, row.mtime_ns), (bytes, newest));
2420            let Section::Summary(summary) = &report.sections[1] else { panic!("summary") };
2421            assert_eq!(summary.bytes, bytes);
2422            let Section::Metrics { summary, .. } = &report.sections[2] else { panic!("types") };
2423            assert_eq!(summary.rows.iter().map(|r| r.bytes).sum::<u64>(), bytes);
2424        }
2425        let selection = Selection {
2426            include: vec![pattern("build")],
2427            exclude: vec![pattern("cache")],
2428            kinds: vec![EntryKind::Dir],
2429            ..Selection::default()
2430        };
2431        let report = run(&index, &query(&[ViewSpec::Files, ViewSpec::Summary], selection));
2432        assert_eq!(files_of(&report)[0].bytes, 0);
2433        let Section::Summary(summary) = &report.sections[1] else { panic!("summary") };
2434        assert_eq!((summary.files, summary.dirs, summary.bytes), (0, 1, 0));
2435        let only = Selection {
2436            include: vec![pattern("cache")],
2437            kinds: vec![EntryKind::Dir],
2438            ignored: IgnoredEntries::Only,
2439            ..Selection::default()
2440        };
2441        assert_eq!(files_of(&run(&index, &query(&[ViewSpec::Files], only)))[0].bytes, 1000);
2442    }
2443
2444    /// A directory whose subtree was not listed in full says so, and its lower-bound
2445    /// activity is not an age: it matches no modification bound, in either direction,
2446    /// while a size bound still holds on the lower bound it can prove.
2447    #[test]
2448    fn an_incomplete_subtree_reports_lower_bounds_and_matches_no_time_bound() {
2449        // `--scan-depth 2`: `env/lib` sits at the boundary, retained and never listed, so
2450        // `env` is incomplete; `docs` holds only files at that depth and is complete.
2451        let mut index = Index::new_with_scope(
2452            "/root",
2453            crate::ScanScope { max_depth: Some(2), ..crate::ScanScope::default() },
2454        );
2455        index.apply_ok(&Observation::new(vec![
2456            upsert("env", EntryKind::Dir, attrs(0, 5)),
2457            upsert("env/lib", EntryKind::Dir, attrs(0, 7)),
2458            upsert("env/a.bin", EntryKind::File, attrs(100, 40)),
2459            upsert("docs", EntryKind::Dir, attrs(0, 5)),
2460            upsert("docs/guide.md", EntryKind::File, attrs(30, 50)),
2461        ]));
2462        let directories = |selection: Selection| {
2463            let selection =
2464                Selection { kinds: vec![EntryKind::Dir], size: SizeMetric::Apparent, ..selection };
2465            files_of(&run(&index, &flat(selection)))
2466                .into_iter()
2467                .map(|row| {
2468                    (row.path.to_string_lossy().into_owned(), row.complete, row.bytes, row.age_ns)
2469                })
2470                .collect::<Vec<_>>()
2471        };
2472        // Joined natively, as the report joins it: Windows spells this row `env\lib`.
2473        let env_lib = Path::new("env").join("lib").to_string_lossy().into_owned();
2474        assert_eq!(
2475            directories(Selection::default()),
2476            vec![
2477                ("env".to_string(), Some(false), 100, None),
2478                ("docs".to_string(), Some(true), 30, Some(-50)),
2479                (env_lib, Some(false), 0, None),
2480            ],
2481            "sizes are lower bounds and the age is unknown below the boundary"
2482        );
2483        for modified in [
2484            ModifiedWindow { since: None, before: Some(100) },
2485            ModifiedWindow { since: Some(0), before: None },
2486        ] {
2487            assert_eq!(
2488                directories(Selection { modified, ..Selection::default() }),
2489                vec![("docs".to_string(), Some(true), 30, Some(-50))],
2490                "an unknown age satisfies no bound, not even one its lower bound would prove"
2491            );
2492        }
2493        assert_eq!(
2494            directories(Selection { min_size: Some(100), ..Selection::default() }),
2495            vec![("env".to_string(), Some(false), 100, None)],
2496            "a lower bound at or above the minimum proves the true size is too"
2497        );
2498        assert!(directories(Selection { min_size: Some(101), ..Selection::default() }).is_empty());
2499        // A regular file has no subtree to be incomplete, and its own age stands.
2500        let files = files_of(&run(
2501            &index,
2502            &flat(Selection {
2503                kinds: vec![EntryKind::File],
2504                modified: ModifiedWindow { since: Some(45), before: None },
2505                ..Selection::default()
2506            }),
2507        ));
2508        assert_eq!(files.len(), 1);
2509        assert_eq!((files[0].complete, files[0].age_ns), (None, Some(-50)));
2510    }
2511
2512    /// A legacy unscoped partial marker carries no authoritative child-list evidence,
2513    /// so no directory row can claim completeness. Scoped scan failures preserve healthy
2514    /// siblings, as the public partial-directory integration test proves.
2515    #[test]
2516    fn an_unscoped_partial_marker_marks_every_directory_row_incomplete() {
2517        let directories = |index: &Index| {
2518            files_of(&run(
2519                index,
2520                &flat(Selection { kinds: vec![EntryKind::Dir], ..Selection::default() }),
2521            ))
2522        };
2523        let mut partial = sample();
2524        partial.set_initial_freshness(false);
2525        let rows = directories(&partial);
2526        assert_eq!(rows.len(), 3);
2527        assert!(rows.iter().all(|row| row.complete == Some(false) && row.age_ns.is_none()));
2528        let mut complete = sample();
2529        complete.set_initial_freshness(true);
2530        let rows = directories(&complete);
2531        assert_eq!(rows.len(), 3);
2532        assert!(rows.iter().all(|row| row.complete == Some(true) && row.age_ns.is_some()));
2533    }
2534
2535    /// While an opened root is discovering, a directory is complete exactly when
2536    /// discovery has listed it, so a report served mid-discovery marks the rest.
2537    #[test]
2538    fn an_opened_root_marks_a_directory_complete_only_once_discovery_listed_it() {
2539        let handle = crate::index::IndexHandle::new(Index::new("/root"));
2540        handle
2541            .transition_discovery(crate::index::DiscoveryTransition::Begin)
2542            .expect("begin discovery");
2543        handle
2544            .apply(&Observation::new(vec![
2545                upsert("known", EntryKind::Dir, attrs(0, 5)),
2546                upsert("pending", EntryKind::Dir, attrs(0, 5)),
2547            ]))
2548            .expect("seed directories");
2549        handle
2550            .apply_discovery(
2551                &Observation::new(Vec::new()),
2552                crate::index::DiscoveryCommit {
2553                    directory_complete: Some(PathBuf::from("known")),
2554                    transition: None,
2555                },
2556            )
2557            .expect("list one directory");
2558        let completeness = handle
2559            .read_with(|index| {
2560                files_of(&run(
2561                    index,
2562                    &flat(Selection { kinds: vec![EntryKind::Dir], ..Selection::default() }),
2563                ))
2564                .into_iter()
2565                .map(|row| (row.path.to_string_lossy().into_owned(), row.complete))
2566                .collect::<BTreeMap<_, _>>()
2567            })
2568            .expect("read");
2569        assert_eq!(
2570            completeness,
2571            BTreeMap::from([
2572                ("known".to_string(), Some(true)),
2573                ("pending".to_string(), Some(false))
2574            ])
2575        );
2576    }
2577
2578    #[test]
2579    fn a_filtered_tree_keeps_empty_matches_and_only_folds_visible_directories() {
2580        let mut index = sample();
2581        index
2582            .apply(&Observation::new(vec![upsert("src/empty", EntryKind::Dir, Attrs::default())]))
2583            .expect("apply");
2584        let selection = Selection {
2585            include: vec![pattern("empty")],
2586            depth: Some(Bound::All),
2587            ..Selection::default()
2588        };
2589        let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
2590        assert_eq!(root.children.len(), 1);
2591        assert_eq!(root.children[0].name, "src");
2592        assert_eq!(root.children[0].children[0].name, "empty");
2593        let selection = Selection {
2594            include: vec![pattern("notes.txt")],
2595            depth: Some(Bound::Limit(0)),
2596            ..Selection::default()
2597        };
2598        let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
2599        assert!(!root.truncated);
2600        assert_eq!(root.bytes, 7);
2601    }
2602
2603    #[test]
2604    fn the_undocumented_docs_view_alias_is_rejected() {
2605        assert_eq!(
2606            ViewSpec::parse("documents").expect("the canonical view name parses"),
2607            ViewSpec::Documents
2608        );
2609        assert_eq!(
2610            ViewSpec::parse("docs").expect_err("an unreleased alias must not become a contract"),
2611            format!("expected one of {}", ViewSpec::vocabulary())
2612        );
2613    }
2614
2615    fn generated_at() -> std::time::SystemTime {
2616        UNIX_EPOCH + Duration::from_secs(1_001)
2617    }
2618
2619    fn run(index: &Index, query: &Query) -> Report {
2620        report(index, &crate::test_support::read_of(index, query.clone()), generated_at())
2621            .expect("the query is answerable over this index")
2622    }
2623
2624    fn query(views: &[ViewSpec], selection: Selection) -> Query {
2625        Query { selection, views: views.to_vec(), ..Query::default() }
2626    }
2627
2628    /// A flat List: the projection whose rows carry subtree metrics and completeness.
2629    fn flat(selection: Selection) -> Query {
2630        Query {
2631            selection,
2632            views: vec![ViewSpec::List],
2633            format: crate::report_format::Format::Paths,
2634            ..Query::default()
2635        }
2636    }
2637
2638    fn pattern(source: &str) -> Pattern {
2639        Pattern::parse(source).expect("pattern compiles")
2640    }
2641
2642    fn summary_of(report: &Report) -> SummaryRow {
2643        match report.sections.first().expect("a section") {
2644            Section::Summary(row) => *row,
2645            other => panic!("expected a summary, got {other:?}"),
2646        }
2647    }
2648
2649    fn files_of(report: &Report) -> Vec<FileRow> {
2650        match report.sections.first().expect("a section") {
2651            Section::Files { rows, .. } => rows.clone(),
2652            other => panic!("expected files, got {other:?}"),
2653        }
2654    }
2655
2656    fn types_of(report: &Report) -> Vec<TypeRow> {
2657        match report.sections.first().expect("a section") {
2658            Section::Extensions { rows, .. } => rows.clone(),
2659            other => panic!("expected types, got {other:?}"),
2660        }
2661    }
2662
2663    fn tree_of(report: &Report) -> TreeNode {
2664        match report.sections.first().expect("a section") {
2665            Section::Tree { root: node, .. } => node.clone(),
2666            other => panic!("expected a tree, got {other:?}"),
2667        }
2668    }
2669
2670    #[test]
2671    fn an_unfiltered_summary_matches_the_precomputed_rollup() {
2672        let index = sample();
2673        let row = summary_of(&run(&index, &query(&[ViewSpec::Summary], Selection::default())));
2674        assert_eq!(row.files, 5);
2675        assert_eq!(row.dirs, 3);
2676        assert_eq!(row.bytes, 657);
2677        assert_eq!(row.newest_mtime_ns, Some(40));
2678    }
2679
2680    #[test]
2681    fn the_two_tiers_agree_on_the_same_question() {
2682        // The load-bearing property: reading pre-computed roll-ups and re-aggregating a
2683        // filtered walk must answer identically when the filter admits everything.
2684        let index = sample();
2685        let fast = summary_of(&run(&index, &query(&[ViewSpec::Summary], Selection::default())));
2686
2687        // A filter that excludes nothing still forces the traversal tier.
2688        let admits_everything = Selection { min_size: Some(0), ..Selection::default() };
2689        assert!(!admits_everything.is_unfiltered());
2690        let slow = summary_of(&run(&index, &query(&[ViewSpec::Summary], admits_everything)));
2691
2692        // `dirs` belongs in this comparison like every other tally. It used to be left
2693        // out because the two tiers genuinely disagreed: the traversal tier counted every
2694        // directory it descended into, so a filter admitting everything was the only
2695        // filter the two tiers could agree under.
2696        assert_eq!(
2697            (fast.files, fast.dirs, fast.bytes, fast.allocated, fast.newest_mtime_ns),
2698            (slow.files, slow.dirs, slow.bytes, slow.allocated, slow.newest_mtime_ns)
2699        );
2700    }
2701
2702    #[test]
2703    fn extension_rows_account_for_every_file_in_both_tiers() {
2704        // The rows are a partition of the tree, not a selection from it, so they have to
2705        // sum to what the summary reports. They did not: a name with no extension was
2706        // dropped from the roll-up rather than bucketed, so a 657-byte tree came back as
2707        // rows totalling less and nothing in the output said which files were missing.
2708        let mut index = sample();
2709        index
2710            .apply(&Observation::new(vec![
2711                upsert("Makefile", EntryKind::File, attrs(28, 50)),
2712                upsert(".gitignore", EntryKind::File, attrs(11, 51)),
2713            ]))
2714            .expect("apply");
2715
2716        // Both tiers: unfiltered reads the pre-computed roll-up, and any filter at all
2717        // forces the traversal to re-aggregate. They are separate code paths.
2718        for selection in [
2719            Selection::default(),
2720            Selection { min_size: Some(0), ..Selection::default() },
2721            Selection { kinds: vec![EntryKind::File], ..Selection::default() },
2722        ] {
2723            let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], selection.clone())));
2724            let summary = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection.clone())));
2725            assert_eq!(
2726                rows.iter().map(|row| row.bytes).sum::<u64>(),
2727                summary.bytes,
2728                "bytes unaccounted for under {selection:?}: {rows:?}"
2729            );
2730            assert_eq!(
2731                rows.iter().map(|row| row.files).sum::<u64>(),
2732                summary.files,
2733                "files unaccounted for under {selection:?}: {rows:?}"
2734            );
2735        }
2736    }
2737
2738    #[test]
2739    fn names_without_an_extension_share_one_bucket() {
2740        // `Makefile` and `.gitignore` have nothing in common as names, and inventing a
2741        // row per such name would turn the view into a file listing. One bucket keeps it
2742        // a roll-up while still accounting for the bytes.
2743        let mut index = sample();
2744        index
2745            .apply(&Observation::new(vec![
2746                upsert("Makefile", EntryKind::File, attrs(28, 50)),
2747                upsert(".gitignore", EntryKind::File, attrs(11, 51)),
2748            ]))
2749            .expect("apply");
2750
2751        let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], Selection::default())));
2752        let bucket = rows
2753            .iter()
2754            .find(|row| row.extension == crate::classify::NO_EXTENSION)
2755            .expect("a bucket for the extension-less names");
2756        assert_eq!(bucket.files, 2);
2757        assert_eq!(bucket.bytes, 39);
2758        // And it never swallows a name that does have one.
2759        assert!(rows.iter().any(|row| row.extension == ".rs"), "{rows:?}");
2760    }
2761
2762    #[test]
2763    fn a_summary_counts_the_union_of_listed_entries_and_directory_contents() {
2764        // One query must not give two answers. The directory tally is folded from the
2765        // walk while the files view is filtered entry by entry, so they are two paths to
2766        // the same number and drifted apart: `--kind file` answered "5 files, 3
2767        // directories" while the files view under the same selection listed no directory
2768        // at all.
2769        let index = sample();
2770        for selection in [
2771            Selection { kinds: vec![EntryKind::File], ..Selection::default() },
2772            Selection { kinds: vec![EntryKind::Dir], ..Selection::default() },
2773            Selection { include: vec![pattern("*.rs")], ..Selection::default() },
2774            Selection { exclude: vec![pattern("docs")], ..Selection::default() },
2775            Selection { min_size: Some(1_000_000), ..Selection::default() },
2776            Selection { min_size: Some(0), ..Selection::default() },
2777        ] {
2778            let summary = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection.clone())));
2779            let listed = files_of(&run(&index, &query(&[ViewSpec::Files], selection.clone())));
2780            let dirs = listed.iter().filter(|row| row.kind == EntryKind::Dir).count() as u64;
2781            let files = every_entry(&index)
2782                .iter()
2783                .filter(|entry| {
2784                    entry.kind == EntryKind::File
2785                        && listed.iter().any(|row| {
2786                            entry.path == row.path
2787                                || (row.kind == EntryKind::Dir && entry.path.starts_with(&row.path))
2788                        })
2789                })
2790                .count() as u64;
2791            assert_eq!(summary.dirs, dirs, "directory counts disagree under {selection:?}");
2792            assert_eq!(summary.files, files, "file counts disagree under {selection:?}");
2793        }
2794    }
2795
2796    #[test]
2797    fn a_rejected_directory_is_still_descended_into() {
2798        // Filtering a directory out of the tally must not filter out what is under it:
2799        // `--kind file` reports no directories and every file, at every depth.
2800        let index = sample();
2801        let selection = Selection { kinds: vec![EntryKind::File], ..Selection::default() };
2802        let row = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection)));
2803        assert_eq!(row.dirs, 0, "no directory was admitted");
2804        assert_eq!(row.files, 5, "including src/deep/nested.rs, two levels down");
2805        assert_eq!(row.bytes, 657, "and its bytes");
2806    }
2807
2808    #[test]
2809    fn nested_directory_counts_roll_up_through_every_level() {
2810        // The tally is taken in the pre-order pass and folded in the post-order one, so a
2811        // directory admitted three levels down has to reach the root through both.
2812        let index = sample();
2813        let selection = Selection { kinds: vec![EntryKind::Dir], ..Selection::default() };
2814        let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
2815        assert_eq!(root.dirs, 3, "src, src/deep, and docs");
2816        assert_eq!(root.files, 4, "matching directories cover their regular files");
2817        let src = root.children.iter().find(|node| node.name == "src").expect("src");
2818        assert_eq!(src.dirs, 1, "src/deep, counted for src as well as for the root");
2819    }
2820
2821    #[test]
2822    fn selection_narrows_a_summary_to_what_it_admits() {
2823        let index = sample();
2824        let selection = Selection { include: vec![pattern("*.rs")], ..Selection::default() };
2825        let row = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection)));
2826        assert_eq!(row.files, 3, "three .rs files");
2827        assert_eq!(row.bytes, 350);
2828    }
2829
2830    #[test]
2831    fn a_files_view_lists_matching_entries_in_name_order_by_default() {
2832        let index = sample();
2833        let selection = Selection { include: vec![pattern("*.rs")], ..Selection::default() };
2834        let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
2835        // Built from components so the expectation carries the native separator: a
2836        // literal "src/main.rs" passes on Unix and fails on Windows for a reason that
2837        // has nothing to do with the view under test.
2838        let paths: Vec<PathBuf> = rows.iter().map(|row| row.path.clone()).collect();
2839        let expected: Vec<PathBuf> = [["src", "deep", "nested.rs"].iter().collect::<PathBuf>()]
2840            .into_iter()
2841            .chain([["src", "lib.rs"].iter().collect::<PathBuf>()])
2842            .chain([["src", "main.rs"].iter().collect::<PathBuf>()])
2843            .collect();
2844        assert_eq!(paths, expected);
2845    }
2846
2847    #[test]
2848    fn sorting_and_limiting_compose_without_a_dedicated_view() {
2849        // "Largest files" is not a view; it is files plus sort plus limit.
2850        let index = sample();
2851        // Apparent, because the sample's allocated sizes round to 512-byte blocks and tie.
2852        let selection = Selection {
2853            kinds: vec![EntryKind::File],
2854            sort: Some(SortKey::Size),
2855            limit: Some(Bound::Limit(2)),
2856            size: SizeMetric::Apparent,
2857            ..Selection::default()
2858        };
2859        let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
2860        assert_eq!(rows.len(), 2);
2861        assert_eq!(rows[0].bytes, 300, "largest first");
2862        assert_eq!(rows[1].bytes, 200);
2863    }
2864
2865    #[test]
2866    fn reverse_flips_whatever_order_is_in_effect() {
2867        let index = sample();
2868        let selection = Selection {
2869            kinds: vec![EntryKind::File],
2870            sort: Some(SortKey::Size),
2871            reverse: true,
2872            size: SizeMetric::Apparent,
2873            ..Selection::default()
2874        };
2875        let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
2876        assert_eq!(rows[0].bytes, 7, "smallest first once reversed");
2877    }
2878
2879    #[test]
2880    fn a_modified_window_selects_by_time() {
2881        let index = sample();
2882        let selection = Selection {
2883            kinds: vec![EntryKind::File],
2884            modified: ModifiedWindow { since: Some(20), before: Some(40) },
2885            sort: Some(SortKey::Mtime),
2886            ..Selection::default()
2887        };
2888        let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
2889        let mut times: Vec<i64> = rows.iter().map(|row| row.mtime_ns).collect();
2890        times.sort_unstable();
2891        assert_eq!(times, vec![20, 30], "inclusive start, exclusive end");
2892    }
2893
2894    #[test]
2895    fn a_types_view_reports_both_size_metrics_per_extension() {
2896        let index = sample();
2897        let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], Selection::default())));
2898        let rs = rows.iter().find(|row| row.extension == ".rs").expect(".rs present");
2899        assert_eq!((rs.files, rs.bytes), (3, 350));
2900        assert_eq!(rs.allocated, 1536, "three files, one 512-byte block each");
2901        // Size-ranked by default: .rs (350) then .md (300) then .txt (7).
2902        let order: Vec<&str> = rows.iter().map(|row| row.extension.as_str()).collect();
2903        assert_eq!(order, vec![".rs", ".md", ".txt"]);
2904    }
2905
2906    #[test]
2907    fn a_tree_view_reports_directories_with_their_subtree_totals() {
2908        let index = sample();
2909        let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], Selection::default())));
2910        assert_eq!(tree.name, ".");
2911        assert_eq!(tree.bytes, 657);
2912        // Size-ranked children: src (350) before docs (300).
2913        let names: Vec<&str> = tree.children.iter().map(|child| child.name.as_str()).collect();
2914        assert_eq!(names, vec!["src", "docs"]);
2915        let src = &tree.children[0];
2916        assert_eq!(src.bytes, 350);
2917        let nested: Vec<&str> = src.children.iter().map(|child| child.name.as_str()).collect();
2918        assert_eq!(nested, vec!["deep"]);
2919    }
2920
2921    #[test]
2922    fn depth_zero_keeps_dus_meaning_of_root_totals_only() {
2923        let index = sample();
2924        let selection = Selection { depth: Some(Bound::Limit(0)), ..Selection::default() };
2925        let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
2926        assert_eq!(tree.bytes, 657, "totals still cover the whole tree");
2927        assert!(tree.children.is_empty(), "but nothing below the root is listed");
2928        assert!(tree.truncated, "and the report says so rather than implying emptiness");
2929    }
2930
2931    /// A view `full` had to drop is named on the report, so every surface says so.
2932    ///
2933    /// This lived in the CLI, which meant a caller reaching the same wall through the
2934    /// library got a report quietly missing a section and no way to learn why (fdu-x8u6).
2935    #[test]
2936    fn a_dropped_view_is_named_on_the_report_rather_than_by_one_surface() {
2937        let (selected, omitted) = ViewSpec::resolve(Some("full"), AnalysisSet::NONE, "view")
2938            .expect("full resolves without analyzers");
2939        assert!(!omitted.is_empty(), "documents needs analysis and must be dropped");
2940
2941        let query = Query { views: selected, omitted_views: omitted, ..Query::default() };
2942        let notes = display_notes(&query, &ControlCoverage::NotObserved);
2943        assert_eq!(notes.len(), 1, "{notes:?}");
2944        assert!(notes[0].contains("omitted documents"), "{notes:?}");
2945
2946        // Nothing dropped, nothing said.
2947        let (selected, omitted) = ViewSpec::resolve(Some("full"), AnalysisSet::ALL, "view")
2948            .expect("full resolves with analyzers");
2949        assert!(omitted.is_empty(), "every view is answerable with analysis enabled");
2950        let query = Query { views: selected, omitted_views: omitted, ..Query::default() };
2951        assert!(display_notes(&query, &ControlCoverage::NotObserved).is_empty());
2952    }
2953
2954    /// A rule belongs to the library; the words a caller can act on belong to their
2955    /// surface. Both diagnostics name two axes, and naming them with flags told a Python
2956    /// caller to add an `--analyze` their surface does not have (fdu-4apt).
2957    ///
2958    /// Asserted as "names mine, never the other's" rather than by quoting either sentence,
2959    /// so rewording the rule cannot break this and changing the vocabulary cannot pass it.
2960    /// The refused-controls note names a few directories and counts the rest, breaks the
2961    /// reasons down only when every refusal is listed, and raises exactly the limits that
2962    /// fired, each by its own knob.
2963    #[test]
2964    fn the_refused_controls_note_bounds_its_list_and_matches_its_remedy_to_the_reasons() {
2965        use crate::control::{
2966            ControlLimits, ControlObservation, ControlRefusalReason, RefusedControl,
2967        };
2968
2969        let refused = |directory: &str, reason| RefusedControl {
2970            path: Path::new(directory).join(".gitignore"),
2971            reason,
2972        };
2973        let note = |limits, refusals: Vec<RefusedControl>, count: u64| {
2974            let coverage = ControlCoverage::Observed(ControlObservation {
2975                limits,
2976                applied: 7,
2977                refused: count,
2978                refusals,
2979            });
2980            refused_controls_note(&coverage, &AxisNames::FLAGS).expect("a refusal is noted")
2981        };
2982        let defaults = ControlLimits::default();
2983        let (budget, line_limit) = (ControlRefusalReason::Budget, ControlRefusalReason::LineLimit);
2984
2985        assert_eq!(
2986            note(defaults, vec![refused("", budget), refused("pkg/a", budget)], 2),
2987            "note: 2 .gitignore files not applied (2 over the 4.0 MiB ignore-rule budget), so \
2988             ignored shares under ., pkg/a are not exact; sizes are. To apply them, raise \
2989             --gitignore-budget above 4.0 MiB, or set it to all"
2990        );
2991        assert_eq!(
2992            note(defaults, vec![refused("vendor", line_limit)], 1),
2993            "note: 1 .gitignore file not applied (1 with a line over the 16 KiB line limit), so \
2994             ignored shares under vendor are not exact; sizes are. To apply them, raise \
2995             --gitignore-line-limit above 16 KiB, or set it to all"
2996        );
2997        assert_eq!(
2998            note(defaults, vec![refused("a", budget), refused("b", line_limit)], 2),
2999            "note: 2 .gitignore files not applied (1 over the 4.0 MiB ignore-rule budget, 1 with \
3000             a line over the 16 KiB line limit), so ignored shares under a, b are not exact; \
3001             sizes are. To apply them, raise --gitignore-budget above 4.0 MiB and \
3002             --gitignore-line-limit above 16 KiB, or set them to all"
3003        );
3004
3005        // Truncated, the note names every limit that could have refused an unlisted file:
3006        // both when both are bounded, and only the budget once the line limit is lifted.
3007        let listed: Vec<_> = (0..crate::MAX_RETAINED_ISSUES)
3008            .map(|index| refused(&format!("d{index:02}"), budget))
3009            .collect();
3010        assert_eq!(
3011            note(defaults, listed.clone(), 1_000),
3012            "note: 1,000 .gitignore files not applied (over the 4.0 MiB ignore-rule budget or \
3013             with a line over the 16 KiB line limit), so ignored shares under d00, d01, d02, \
3014             d03, d04, 995 more are not exact; sizes are. To apply them, raise \
3015             --gitignore-budget above 4.0 MiB and --gitignore-line-limit above 16 KiB, or set \
3016             them to all"
3017        );
3018        assert_eq!(
3019            note(ControlLimits { line_limit: None, ..defaults }, listed, 1_000),
3020            "note: 1,000 .gitignore files not applied (over the 4.0 MiB ignore-rule budget), so \
3021             ignored shares under d00, d01, d02, d03, d04, 995 more are not exact; sizes are. \
3022             To apply them, raise --gitignore-budget above 4.0 MiB, or set it to all"
3023        );
3024
3025        // No engine path records a refusal by an unbounded limit, and the snapshot loader
3026        // rejects one, but a hand-built observation can still carry it: the note names the
3027        // limit without a size rather than a zero one, and raises only bounded limits.
3028        assert_eq!(
3029            note(ControlLimits { budget: None, ..defaults }, vec![refused("a", budget)], 1),
3030            "note: 1 .gitignore file not applied (1 over the ignore-rule budget), so ignored \
3031             shares under a are not exact; sizes are."
3032        );
3033
3034        let complete = ControlCoverage::Observed(ControlObservation {
3035            limits: defaults,
3036            applied: 3,
3037            refused: 0,
3038            refusals: Vec::new(),
3039        });
3040        assert_eq!(refused_controls_note(&complete, &AxisNames::FLAGS), None);
3041        assert_eq!(refused_controls_note(&ControlCoverage::NotObserved, &AxisNames::FLAGS), None);
3042    }
3043
3044    #[test]
3045    fn a_diagnostic_names_the_axes_the_requesting_surface_uses() {
3046        let (selected, omitted) = ViewSpec::resolve(Some("full"), AnalysisSet::NONE, "view")
3047            .expect("full resolves without analyzers");
3048
3049        for (axes, mine, theirs) in [
3050            (&AxisNames::FLAGS, "--analyze", "analyze"),
3051            (&AxisNames::FIELDS, "analyze", "--analyze"),
3052        ] {
3053            let query = Query {
3054                views: selected.clone(),
3055                omitted_views: omitted.clone(),
3056                axes,
3057                ..Query::default()
3058            };
3059            // Anchored on the whole phrase, because `--analyze` contains `analyze`: a bare
3060            // `contains` for the other surface's spelling matches its own. That is the same
3061            // tokenisation trap the watch-scope substitution had to avoid.
3062            let note = display_notes(&query, &ControlCoverage::NotObserved).remove(0);
3063            assert!(note.contains(&format!("add {mine} ")), "{note} must name {mine}");
3064            assert!(!note.contains(&format!("add {theirs} ")), "{note} must not name {theirs}");
3065        }
3066
3067        // The same for the hard error, which names the view axis as well. The rule is the
3068        // request model's; what a surface reads is this rendering of it.
3069        for (axes, view, analyze) in
3070            [(&AxisNames::FLAGS, "--view", "--analyze"), (&AxisNames::FIELDS, "view", "analyze")]
3071        {
3072            let error =
3073                crate::query::RequestError::ViewNeedsContent(ViewSpec::Documents).message(axes);
3074            assert!(error.starts_with(&format!("{view} documents")), "{error}");
3075            assert!(error.contains(&format!("add {analyze} ")), "{error}");
3076            let theirs = if analyze == "--analyze" { "analyze" } else { "--analyze" };
3077            assert!(!error.contains(&format!("add {theirs} ")), "{error}");
3078        }
3079    }
3080
3081    /// A library caller who never says otherwise is not the command line, so the default
3082    /// vocabulary is the one their surface uses.
3083    #[test]
3084    fn the_default_vocabulary_is_the_librarys_own() {
3085        assert_eq!(*Query::default().axes, AxisNames::FIELDS);
3086    }
3087
3088    #[test]
3089    fn a_depth_bound_marks_only_hidden_directory_rows_as_truncated() {
3090        let index = sample();
3091        let selection = Selection { depth: Some(Bound::Limit(1)), ..Selection::default() };
3092        let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
3093
3094        let src = tree.children.iter().find(|child| child.name == "src").expect("src");
3095        assert!(src.truncated, "src with a hidden directory child is truncated");
3096
3097        let docs = tree.children.iter().find(|child| child.name == "docs").expect("docs");
3098        assert!(docs.children.is_empty());
3099        assert!(
3100            !docs.truncated,
3101            "file children contribute to a directory row; they are not hidden tree rows"
3102        );
3103    }
3104
3105    #[test]
3106    fn a_tree_limit_bounds_entries_per_directory_and_marks_truncation() {
3107        let index = sample();
3108        let selection = Selection { limit: Some(Bound::Limit(1)), ..Selection::default() };
3109        let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
3110        assert_eq!(tree.children.len(), 1);
3111        assert!(tree.truncated);
3112    }
3113
3114    #[test]
3115    fn requesting_more_views_never_changes_another_views_answer() {
3116        // The property that makes `--view types,tree` one scan and one consistent state.
3117        let index = sample();
3118        let alone = types_of(&run(&index, &query(&[ViewSpec::Extensions], Selection::default())));
3119        let together = run(
3120            &index,
3121            &query(
3122                &[ViewSpec::Extensions, ViewSpec::Tree, ViewSpec::Summary],
3123                Selection::default(),
3124            ),
3125        );
3126        let with_others = match &together.sections[0] {
3127            Section::Extensions { rows, .. } => rows.clone(),
3128            other => panic!("expected types first, got {other:?}"),
3129        };
3130
3131        assert_eq!(alone.len(), with_others.len());
3132        for (left, right) in alone.iter().zip(with_others.iter()) {
3133            assert_eq!(
3134                (&left.extension, left.files, left.bytes),
3135                (&right.extension, right.files, right.bytes)
3136            );
3137        }
3138        assert_eq!(together.sections.len(), 3, "one section per view, in request order");
3139        assert_eq!(together.sections[1].view(), ViewSpec::Tree);
3140        assert_eq!(together.sections[2].view(), ViewSpec::Summary);
3141    }
3142
3143    #[test]
3144    fn analyzed_unfiltered_views_together_match_independent_answers_and_each_view_alone() {
3145        const RUST: &str = "fn main() {\n    println!(\"hi\");\n}\n";
3146        const MARKDOWN: &str = "# Guide\n\nA small useful guide.\n";
3147        const TEXT: &str = "plain notes here\n";
3148        let root = tempfile::tempdir().expect("root");
3149        fs::create_dir_all(root.path().join("src")).expect("src");
3150        fs::create_dir_all(root.path().join("docs")).expect("docs");
3151        fs::write(root.path().join("src/main.rs"), RUST).expect("rust");
3152        fs::write(root.path().join("docs/guide.md"), MARKDOWN).expect("markdown");
3153        fs::write(root.path().join("notes.txt"), TEXT).expect("text");
3154        for (path, seconds) in [("src/main.rs", 10), ("notes.txt", 20), ("docs/guide.md", 30)] {
3155            fs::File::options()
3156                .write(true)
3157                .open(root.path().join(path))
3158                .expect("open for timestamp")
3159                .set_times(
3160                    fs::FileTimes::new().set_modified(UNIX_EPOCH + Duration::from_secs(seconds)),
3161                )
3162                .expect("set timestamp");
3163        }
3164        let (mut index, _) = crate::scan::scan_into_index(
3165            root.path(),
3166            &crate::ScanConfig { read_controls: false, ..crate::ScanConfig::default() },
3167        )
3168        .expect("scan");
3169        crate::content::analyze_index(
3170            &mut index,
3171            crate::content::AnalysisRequest {
3172                profile: AnalysisSet::ALL,
3173                ..crate::content::AnalysisRequest::default()
3174            },
3175        );
3176
3177        let views = [
3178            ViewSpec::Types,
3179            ViewSpec::Families,
3180            ViewSpec::Languages,
3181            ViewSpec::Documents,
3182            ViewSpec::Files,
3183            ViewSpec::Largest,
3184            ViewSpec::Recent,
3185            ViewSpec::Summary,
3186            ViewSpec::Tree,
3187            ViewSpec::Extensions,
3188        ];
3189        let selection = Selection { size: SizeMetric::Apparent, ..Selection::default() };
3190        let together = run(&index, &query(&views, selection.clone()));
3191        for (i, view) in views.iter().enumerate() {
3192            let alone = run(&index, &query(&[*view], selection.clone()));
3193            assert_eq!(
3194                format!("{:?}", together.sections[i]),
3195                format!("{:?}", alone.sections[0]),
3196                "{view:?} changed when requested with the other views"
3197            );
3198        }
3199
3200        for (at, view, files) in [(0, ViewSpec::Types, 3), (1, ViewSpec::Families, 3)] {
3201            let Section::Metrics { view: actual, summary } = &together.sections[at] else {
3202                panic!("expected {view:?} metrics")
3203            };
3204            assert_eq!(*actual, view);
3205            assert_eq!(summary.total.files, files);
3206            assert_eq!(summary.total.analyzed_files, files);
3207        }
3208        let Section::Metrics { summary: languages, .. } = &together.sections[2] else {
3209            panic!("languages")
3210        };
3211        assert_eq!(languages.total.files, 1);
3212        assert_eq!(languages.total.metrics.code_lines, Some(3));
3213        let Section::Metrics { summary: documents, .. } = &together.sections[3] else {
3214            panic!("documents")
3215        };
3216        assert_eq!(documents.total.files, 2);
3217        assert_eq!(documents.total.analyzed_files, 2);
3218        assert_eq!(documents.total.document_metric_files, 2);
3219        assert_eq!(documents.total.document_raw_words, 8);
3220        assert_eq!(documents.total.document_word_stats.logical_words(), 8);
3221        assert_eq!(documents.total.share, MetricShare { numerator: 8, denominator: 8 });
3222
3223        let Section::Files { rows: files, total, .. } = &together.sections[4] else {
3224            panic!("files")
3225        };
3226        assert_eq!(*total, 5);
3227        assert_eq!(
3228            files.iter().map(|row| row.path.as_path()).collect::<Vec<_>>(),
3229            ["docs", "docs/guide.md", "notes.txt", "src", "src/main.rs"].map(Path::new).to_vec()
3230        );
3231        let Section::Files { rows: largest, total, .. } = &together.sections[5] else {
3232            panic!("largest")
3233        };
3234        assert_eq!(*total, 3);
3235        assert_eq!(
3236            largest.iter().map(|row| row.path.as_path()).collect::<Vec<_>>(),
3237            ["src/main.rs", "docs/guide.md", "notes.txt"].map(Path::new).to_vec()
3238        );
3239        let Section::Files { rows: recent, total, .. } = &together.sections[6] else {
3240            panic!("recent")
3241        };
3242        assert_eq!(*total, 3);
3243        assert_eq!(
3244            recent.iter().map(|row| row.path.as_path()).collect::<Vec<_>>(),
3245            ["docs/guide.md", "notes.txt", "src/main.rs"].map(Path::new).to_vec()
3246        );
3247        let Section::Summary(summary) = &together.sections[7] else { panic!("summary") };
3248        assert_eq!((summary.files, summary.dirs), (3, 2));
3249        assert_eq!(
3250            summary.bytes,
3251            u64::try_from(RUST.len() + MARKDOWN.len() + TEXT.len()).expect("fixture bytes")
3252        );
3253        let Section::Tree { root: tree, .. } = &together.sections[8] else { panic!("tree") };
3254        assert_eq!((tree.files, tree.dirs), (3, 2));
3255        let Section::Extensions { rows, total } = &together.sections[9] else {
3256            panic!("extensions")
3257        };
3258        assert_eq!((*total, rows.len()), (3, 3));
3259    }
3260
3261    #[test]
3262    fn a_report_derives_provenance_from_its_index() {
3263        let index = sample();
3264        let report = run(&index, &query(&[ViewSpec::Summary], Selection::default()));
3265        assert_eq!(report.provenance.source, ReportSource::ColdScan);
3266        assert!(report.status.complete);
3267        assert!(report.provenance.scan_started_at.is_some());
3268        assert_eq!(report.provenance.generated_at, generated_at());
3269        assert_eq!(report.root, Path::new("/root"));
3270    }
3271
3272    #[test]
3273    fn reporting_is_pure_and_repeatable() {
3274        let index = sample();
3275        let request = query(&[ViewSpec::Tree, ViewSpec::Extensions], Selection::default());
3276        assert_eq!(format!("{:?}", run(&index, &request)), format!("{:?}", run(&index, &request)));
3277    }
3278
3279    #[test]
3280    fn metadata_grouping_views_use_the_generic_metric_projection() {
3281        let index = sample();
3282        let apparent = Selection { size: SizeMetric::Apparent, ..Selection::default() };
3283        let report = run(
3284            &index,
3285            &query(&[ViewSpec::Types, ViewSpec::Families, ViewSpec::Languages], apparent),
3286        );
3287        let Section::Metrics { summary: types, .. } = &report.sections[0] else {
3288            panic!("expected type metrics")
3289        };
3290        let rust = types.rows.iter().find(|row| row.id == "rust").expect("rust");
3291        assert_eq!((rust.files, rust.bytes), (3, 350));
3292        assert_eq!((rust.share.numerator, rust.share.denominator), (350, 657));
3293        assert_eq!(types.share_metric, ShareMetric::ApparentBytes);
3294
3295        let Section::Metrics { summary: families, .. } = &report.sections[1] else {
3296            panic!("expected family metrics")
3297        };
3298        assert!(families.rows.iter().any(|row| row.id == "code"));
3299        assert!(families.rows.iter().any(|row| row.id == "prose"));
3300        assert_eq!(families.share_metric, ShareMetric::ApparentBytes);
3301
3302        let Section::Metrics { summary: languages, .. } = &report.sections[2] else {
3303            panic!("expected language metrics")
3304        };
3305        let rust = languages.rows.iter().find(|row| row.id == "rust").expect("rust");
3306        assert_eq!((rust.files, rust.bytes), (3, 350));
3307        assert_eq!((rust.share.numerator, rust.share.denominator), (350, 350));
3308        assert_eq!(languages.share_metric, ShareMetric::ApparentBytes);
3309    }
3310
3311    /// The control file's name, spelled once for the fixtures that write one.
3312    const CONTROL: &str = ".gitignore";
3313
3314    /// A small tree under a control source ignoring `build/` and `*.log`.
3315    fn classified_sample() -> Index {
3316        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
3317        index
3318            .apply(&Observation::new(vec![
3319                Op::ControlUpsert {
3320                    path: PathBuf::from(CONTROL),
3321                    source: b"build/\n*.log\n".to_vec(),
3322                },
3323                upsert("src", EntryKind::Dir, Attrs::default()),
3324                upsert("src/main.rs", EntryKind::File, attrs(100, 10)),
3325                upsert("src/lib.rs", EntryKind::File, attrs(200, 20)),
3326                upsert("src/debug.log", EntryKind::File, attrs(25, 70)),
3327                upsert("docs", EntryKind::Dir, Attrs::default()),
3328                upsert("docs/guide.md", EntryKind::File, attrs(300, 30)),
3329                upsert("build", EntryKind::Dir, Attrs::default()),
3330                upsert("build/cache", EntryKind::Dir, Attrs::default()),
3331                upsert("build/cache/out.bin", EntryKind::File, attrs(1_000, 60)),
3332            ]))
3333            .expect("apply");
3334        index
3335    }
3336
3337    fn ignored_of(row: &SummaryRow) -> IgnoredTally {
3338        row.ignored.expect("an observing index reports an ignored share")
3339    }
3340
3341    /// Both tiers report the same ignored share on every row kind that carries one: the
3342    /// unfiltered tier subtracts the maintained partitions, and the traversal tier counts
3343    /// the entries it admits.
3344    #[test]
3345    fn the_two_tiers_agree_on_the_ignored_share() {
3346        let index = classified_sample();
3347        let expected = IgnoredTally { files: 2, dirs: 2, bytes: 1_025, allocated: 1_024 + 512 };
3348        for selection in
3349            [Selection::default(), Selection { min_size: Some(0), ..Selection::default() }]
3350        {
3351            let unfiltered = selection.is_unfiltered();
3352            let summary = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection.clone())));
3353            assert_eq!(ignored_of(&summary), expected, "unfiltered: {unfiltered}");
3354
3355            let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection.clone())));
3356            assert_eq!(tree.ignored, Some(expected), "unfiltered: {unfiltered}");
3357            let child = |name: &str| {
3358                tree.children.iter().find(|node| node.name == name).expect(name).ignored
3359            };
3360            assert_eq!(
3361                child("build"),
3362                Some(IgnoredTally { files: 1, dirs: 1, bytes: 1_000, allocated: 1_024 }),
3363                "an ignored directory is wholly ignored below it, unfiltered: {unfiltered}"
3364            );
3365            assert_eq!(
3366                child("src"),
3367                Some(IgnoredTally { files: 1, dirs: 0, bytes: 25, allocated: 512 }),
3368                "unfiltered: {unfiltered}"
3369            );
3370            assert_eq!(child("docs"), Some(IgnoredTally::default()), "observed, nothing ignored");
3371
3372            let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], selection.clone())));
3373            let row = |extension: &str| {
3374                rows.iter().find(|row| row.extension == extension).expect(extension).ignored
3375            };
3376            assert_eq!(
3377                row(".log"),
3378                Some(IgnoredTally { files: 1, dirs: 0, bytes: 25, allocated: 512 }),
3379                "unfiltered: {unfiltered}"
3380            );
3381            assert_eq!(row(".rs"), Some(IgnoredTally::default()), "unfiltered: {unfiltered}");
3382
3383            let files = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
3384            let flag =
3385                |path: PathBuf| files.iter().find(|row| row.path == path).map(|row| row.ignored);
3386            assert_eq!(flag(PathBuf::from("build")), Some(Some(true)));
3387            assert_eq!(flag(PathBuf::from("src")), Some(Some(false)));
3388            assert_eq!(flag(["src", "debug.log"].iter().collect()), Some(Some(true)));
3389            assert_eq!(flag(["build", "cache", "out.bin"].iter().collect()), Some(Some(true)));
3390        }
3391    }
3392
3393    /// Excluding ignored entries and selecting only them split the tree into two parts that
3394    /// sum to it, and each sizes and ranks its rows by what it selected.
3395    #[test]
3396    fn ignored_entries_partition_the_tree_and_rank_by_what_they_select() {
3397        let index = classified_sample();
3398        // Apparent, so the ranking is by the distinct sizes the sample wrote rather than by
3399        // the 512-byte blocks they round up to.
3400        let apparent = Selection { size: SizeMetric::Apparent, ..Selection::default() };
3401        let with = |ignored| Selection { ignored, ..apparent.clone() };
3402        let summary = |selection| summary_of(&run(&index, &query(&[ViewSpec::Summary], selection)));
3403        let total = summary(apparent.clone());
3404        let kept = summary(with(IgnoredEntries::Exclude));
3405        let only = summary(with(IgnoredEntries::Only));
3406        assert_eq!(
3407            (kept.files + only.files, kept.dirs + only.dirs, kept.bytes + only.bytes),
3408            (total.files, total.dirs, total.bytes)
3409        );
3410        assert_eq!(ignored_of(&kept), IgnoredTally::default());
3411        let whole = ignored_of(&only);
3412        assert_eq!((whole.files, whole.dirs, whole.bytes), (only.files, only.dirs, only.bytes));
3413
3414        let ranked = |selection| {
3415            tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)))
3416                .children
3417                .iter()
3418                .map(|node| (node.name.clone(), node.bytes))
3419                .collect::<Vec<_>>()
3420        };
3421        let row = |name: &str, bytes: u64| (name.to_string(), bytes);
3422        assert_eq!(
3423            ranked(apparent.clone()),
3424            [row("build", 1_000), row("src", 325), row("docs", 300)]
3425        );
3426        assert_eq!(
3427            ranked(with(IgnoredEntries::Exclude)),
3428            [row("docs", 300), row("src", 300)],
3429            "unignored sizes rank the rows, with the name breaking the tie"
3430        );
3431        assert_eq!(ranked(with(IgnoredEntries::Only)), [row("build", 1_000), row("src", 25)]);
3432    }
3433
3434    /// An index that read no rule reports no ignored share on any row, and refuses a
3435    /// selection by ignored state in the vocabulary of the surface that asked.
3436    #[test]
3437    fn an_index_that_observed_no_control_state_has_no_ignored_share_to_select_by() {
3438        let mut index =
3439            Index::new_with_scope("/root", crate::test_support::not_observing_controls());
3440        index
3441            .apply(&Observation::new(vec![
3442                upsert("build", EntryKind::Dir, Attrs::default()),
3443                upsert("build/out.bin", EntryKind::File, attrs(1_000, 60)),
3444            ]))
3445            .expect("apply");
3446        let views = [ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions, ViewSpec::Files];
3447        let report = run(&index, &query(&views, Selection::default()));
3448        let Section::Summary(summary) = &report.sections[0] else { panic!("a summary") };
3449        let Section::Tree { root: tree, .. } = &report.sections[1] else { panic!("a tree") };
3450        let Section::Extensions { rows: extensions, .. } = &report.sections[2] else {
3451            panic!("extensions")
3452        };
3453        let Section::Files { rows: files, .. } = &report.sections[3] else { panic!("files") };
3454        assert_eq!(summary.ignored, None);
3455        assert_eq!(tree.ignored, None);
3456        assert!(tree.children.iter().all(|node| node.ignored.is_none()));
3457        assert!(extensions.iter().all(|row| row.ignored.is_none()));
3458        assert!(files.iter().all(|row| row.ignored.is_none()));
3459
3460        let exclude = Query {
3461            selection: Selection { ignored: IgnoredEntries::Exclude, ..Selection::default() },
3462            views: vec![ViewSpec::Summary],
3463            ..Query::default()
3464        };
3465        let only = Query {
3466            selection: Selection { ignored: IgnoredEntries::Only, ..Selection::default() },
3467            ..exclude.clone()
3468        };
3469        // The request model refuses such a request before it reaches a reader, in each
3470        // surface's words (`query_request`'s tests). The library path refuses it too, rather
3471        // than answering with no rows: a caller that reaches `report` without validating gets
3472        // the same typed refusal every other surface renders.
3473        for refused in [exclude, only] {
3474            assert!(
3475                matches!(
3476                    super::report(
3477                        &index,
3478                        &crate::test_support::read_of(&index, refused),
3479                        generated_at()
3480                    ),
3481                    Err(crate::Error::InvalidRequest(
3482                        crate::query::RequestError::IgnoredWithoutObservation(_)
3483                    ))
3484                ),
3485                "a selection by ignored state over an unobserving index is refused"
3486            );
3487        }
3488        assert_eq!(summary_of(&run(&index, &query(&views, Selection::default()))).files, 1);
3489    }
3490}