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::{Classification, ContentFamily, DetectionConfidence, DetectionSource};
23use crate::content::{
24    AnalysisSet, CodeMetrics, ContentDetection, ContentProvenance, CoverageReason, FileAnalysis,
25    LogicalWordStats, MetricDef,
26};
27use crate::control::ControlCoverage;
28use crate::engine_contract::{EntryKind, ScanScope};
29use crate::index::{EntryId, ExtTally, Index, RollUpScalars};
30use crate::query::query_request::{Basis, Request};
31use crate::query::query_selection::{
32    Bound, IgnoredEntries, NameIdentity, Selection, ShareThreshold, SizeMetric, SortKey,
33};
34use crate::query::{Rejection, ReportProvenance, TreeStatus, query_subtrees};
35
36/// Which roll-up or listing a view reports.
37#[derive(Clone, Copy, PartialEq, Eq, Debug)]
38pub enum ViewSpec {
39    /// Matching entries, rendered as a tree or a flat list by the format axis.
40    List,
41    /// Per-directory roll-ups down the hierarchy.
42    Tree,
43    /// One row per stable detected file type.
44    Types,
45    /// One row per raw derived extension.
46    Extensions,
47    /// One row per broad content family.
48    Families,
49    /// Code-family rows grouped by language/type.
50    Languages,
51    /// Code totals, language ranking, and population contributions.
52    Code,
53    /// Prose and markup rows with text-volume metrics.
54    Documents,
55    /// A flat listing of matching entries.
56    Files,
57    /// The largest files, by size.
58    ///
59    /// A named preset over [`Self::Files`], not separate machinery:
60    /// `largest ≡ files --sort size --limit 20`, restricted to regular files.
61    Largest,
62    /// The most recently modified files.
63    ///
64    /// `recent ≡ files --sort mtime --limit 20`, restricted to regular files.
65    Recent,
66    /// One aggregate row for everything selected.
67    Summary,
68}
69
70impl ViewSpec {
71    /// The ordering this view uses when the caller did not choose one.
72    fn default_sort(self) -> SortKey {
73        match self {
74            // Size-ranked by default, because "what is big" is the question these answer.
75            Self::List
76            | Self::Tree
77            | Self::Types
78            | Self::Extensions
79            | Self::Families
80            | Self::Languages
81            | Self::Code
82            | Self::Documents
83            | Self::Summary
84            // `largest` lands here for its own reason: it is named for the ranking,
85            // so the ranking is not a display default but the view's whole content.
86            | Self::Largest => SortKey::Size,
87            // A complete listing reads and diffs in name order, which is the only
88            // reason name order is right here: the stability that justifies it
89            // disappears the moment the list is truncated, which is why `files` is
90            // unbounded and the two bounded presets sort by what they are named for.
91            Self::Files => SortKey::Name,
92            Self::Recent => SortKey::Mtime,
93        }
94    }
95
96    /// The bound this view applies when the caller named none.
97    ///
98    /// `files` enumerates, so it is complete: it stands in for `fd` and `find`, and the
99    /// incremental-sync watermark query depends on it — a watermark that silently returns
100    /// twenty of 192,871 changed files loses data rather than merely under-reporting.
101    /// The presets are summaries and bound themselves; every other view keeps the
102    /// display default.
103    const fn default_limit(self) -> Bound {
104        match self {
105            Self::Largest | Self::Recent => Bound::Limit(20),
106            _ => Bound::All,
107        }
108    }
109
110    /// How deep a rendered tree descends when the caller named no depth.
111    ///
112    /// Two levels is what makes `fdu` answer "what is big here" at a glance: the root's
113    /// children and theirs. Only the tree renders a hierarchy at all, so every other
114    /// view is unbounded and the question does not arise.
115    const fn default_depth(self) -> Bound {
116        match self {
117            Self::Tree => Bound::Limit(5),
118            _ => Bound::All,
119        }
120    }
121
122    /// Whether this view reports regular files only.
123    ///
124    /// `tree` already reports directory sizes, so a `largest` that listed directories
125    /// would duplicate it at a coarser grain and push the actual files out of the window.
126    const fn files_only(self) -> bool {
127        matches!(self, Self::Largest | Self::Recent)
128    }
129
130    /// Every view, in the order a full report renders them.
131    ///
132    /// One list, so a front end cannot hold a stale copy: the Python binding kept its own
133    /// view parser and silently rejected `largest` and `recent` for exactly that reason.
134    pub const ALL: [Self; 12] = [
135        Self::List,
136        Self::Summary,
137        Self::Tree,
138        Self::Families,
139        Self::Types,
140        Self::Extensions,
141        Self::Languages,
142        Self::Code,
143        Self::Documents,
144        Self::Largest,
145        Self::Recent,
146        Self::Files,
147    ];
148
149    /// Parse one view name.
150    ///
151    /// Lives here rather than in a front end because it is the axis's grammar, not one
152    /// surface's flag parsing — the CLI and the Python binding must accept exactly the
153    /// same words or the two disagree about what a request means.
154    pub fn parse(value: &str) -> Result<Self, String> {
155        match value.trim().to_ascii_lowercase().as_str() {
156            "list" => Ok(Self::List),
157            "tree" => Ok(Self::Tree),
158            "types" => Ok(Self::Types),
159            "extensions" => Ok(Self::Extensions),
160            "families" => Ok(Self::Families),
161            "languages" => Ok(Self::Languages),
162            "code" => Ok(Self::Code),
163            "documents" => Ok(Self::Documents),
164            "largest" => Ok(Self::Largest),
165            "recent" => Ok(Self::Recent),
166            "files" => Ok(Self::Files),
167            "summary" => Ok(Self::Summary),
168            // The expectation only. Each front end names its own flag and quotes the
169            // offending token, so neither ends up saying "invalid --view invalid view".
170            _ => Err(format!("expected one of {}", Self::vocabulary())),
171        }
172    }
173
174    /// The accepted spellings, for an error message that teaches the vocabulary.
175    pub fn vocabulary() -> String {
176        let mut names: Vec<&str> = Self::ALL.iter().map(|view| view.label()).collect();
177        names.push("full");
178        names.join(", ")
179    }
180
181    /// Stable wire label.
182    pub const fn label(self) -> &'static str {
183        match self {
184            Self::List => "list",
185            Self::Tree => "tree",
186            Self::Types => "types",
187            Self::Extensions => "extensions",
188            Self::Families => "families",
189            Self::Languages => "languages",
190            Self::Code => "code",
191            Self::Documents => "documents",
192            Self::Largest => "largest",
193            Self::Recent => "recent",
194            Self::Files => "files",
195            Self::Summary => "summary",
196        }
197    }
198
199    /// The analyzers this view shows, and so requests when a request builds its own basis.
200    ///
201    /// Only a view with no metadata meaning implies anything. `code` and `documents`
202    /// display nothing without analysis, so naming one is asking for its analyzer, and
203    /// refusing it would only make the caller type the analyzer as well. `languages`,
204    /// `types`, and `families` gain metrics under an analyzer but are metadata reports
205    /// without one, and `full` is the metadata digest: none of them implies anything,
206    /// because a display choice with a cheap meaning must never turn into a read of every
207    /// file in the tree with nothing in the command to say so.
208    ///
209    /// A match over every view, so a new one forces this decision. A basis an index
210    /// already holds is never widened by it: a read refuses a view its basis cannot
211    /// answer ([`RequestError::ViewNeedsAnalyzer`](crate::query::RequestError)).
212    pub const fn implies(self) -> AnalysisSet {
213        match self {
214            Self::Code => AnalysisSet::CODE_ONLY,
215            Self::Documents => AnalysisSet::WORDS_ONLY,
216            Self::List
217            | Self::Tree
218            | Self::Types
219            | Self::Extensions
220            | Self::Families
221            | Self::Languages
222            | Self::Files
223            | Self::Largest
224            | Self::Recent
225            | Self::Summary => AnalysisSet::NONE,
226        }
227    }
228
229    /// The analyzers whose results this view displays, when its basis holds them.
230    ///
231    /// The other half of [`Self::implies`]: a view shows everything it implies, and the
232    /// grouping views show more than they imply. `types`, `families`, and `languages` add
233    /// line, code, and word columns to each row under whichever analyzers ran, while `code`
234    /// shows only code analysis and `documents` only words. What a report says about
235    /// analysis no selected view displays is decided against this table, so a request that
236    /// pays for code analysis and shows only `documents` says so rather than staying silent
237    /// because one view displayed something.
238    ///
239    /// A match over every view, so a new one forces this decision too.
240    pub const fn shows(self) -> AnalysisSet {
241        match self {
242            Self::Types | Self::Families | Self::Languages => AnalysisSet::ALL,
243            Self::Code => AnalysisSet::CODE_ONLY,
244            Self::Documents => AnalysisSet::WORDS_ONLY,
245            Self::List
246            | Self::Tree
247            | Self::Extensions
248            | Self::Files
249            | Self::Largest
250            | Self::Recent
251            | Self::Summary => AnalysisSet::NONE,
252        }
253    }
254
255    /// The view a request displays its analysis in when the caller named none.
256    ///
257    /// The converse of [`Self::implies`], and free for the same reason: it re-projects
258    /// state the request already pays for. Without it, a request that reads every
259    /// eligible file reports a directory tree containing none of the results. For each
260    /// view that implies an analyzer, this is that view, which a test pins so the two
261    /// tables cannot drift.
262    pub const fn default_for(analysis: AnalysisSet) -> Self {
263        match (analysis.includes_code(), analysis.includes_words()) {
264            (true, _) => Self::Code,
265            (false, true) => Self::Documents,
266            (false, false) if analysis.is_enabled() => Self::Families,
267            (false, false) => Self::List,
268        }
269    }
270
271    /// Resolve the view axis from a caller's spec against what the analyzers can answer.
272    ///
273    /// The whole job in one place: the list grammar, `full` expansion, and the default
274    /// when the caller named nothing. All three lived in the CLI, so `--view tree,tree`
275    /// was a typo there and a silent no-op through the Python API -- one request meaning
276    /// two things depending on which door it came through (fdu-jozr) -- and the binding
277    /// kept its own partial copy that had already drifted (fdu-ggux, fdu-gw5b).
278    ///
279    /// Returns the views to render and the ones `full` had to drop, so a caller can state
280    /// the omission rather than hide it.
281    ///
282    /// `label` names the axis as the calling surface spells it, for the reason
283    /// `AnalysisSet::parse_labeled` takes one: rewriting the message afterwards hits the
284    /// user's own token (fdu-7j6z).
285    pub fn resolve(
286        spec: Option<&str>,
287        analysis: AnalysisSet,
288        label: &str,
289    ) -> Result<(Vec<Self>, Vec<Self>), String> {
290        Self::resolve_rejecting(spec, analysis).map_err(|rejection| rejection.labeled(label))
291    }
292
293    /// [`Self::resolve`], refusing with the value and expectation rather than a sentence, so
294    /// the request model can name the axis in a typed refusal.
295    pub(crate) fn resolve_rejecting(
296        spec: Option<&str>,
297        analysis: AnalysisSet,
298    ) -> Result<(Vec<Self>, Vec<Self>), Rejection> {
299        ViewList::parse(spec).map(|views| views.resolve(analysis))
300    }
301
302    /// The views a request displays when its caller named none: the one
303    /// [`Self::default_for`] the analyzers, or both content views when both analyzers ran.
304    pub fn defaults_for(analysis: AnalysisSet) -> Vec<Self> {
305        if analysis.includes_code() && analysis.includes_words() {
306            vec![Self::Code, Self::Documents]
307        } else {
308            vec![Self::default_for(analysis)]
309        }
310    }
311
312    /// Why `full` cannot appear beside another view.
313    ///
314    /// Stated once, here, because it was stated twice: the CLI and the Python binding
315    /// each carried their own copy, and the binding's had lost the trailing clause. Two
316    /// copies of one rule drift silently, and a parity test comparing surface to surface
317    /// only catches it when the drift reaches the wording (fdu-gw5b).
318    pub const FULL_IS_EXCLUSIVE: &'static str =
319        "it names the whole report and cannot be combined with another view";
320
321    /// The summary views `full` expands to, given what the analyzers can answer.
322    ///
323    /// Returns the satisfiable views and those it had to skip, so a caller can report the
324    /// omission rather than drop it silently. A view is satisfiable when the analyzers
325    /// include everything it [`implies`](Self::implies), the same test a read applies to a
326    /// view its caller named: `full` implies nothing, so it never reads for a view it
327    /// contains.
328    pub fn full_report(analysis: AnalysisSet) -> (Vec<Self>, Vec<Self>) {
329        Self::ALL
330            .into_iter()
331            .filter(|view| {
332                view.is_summary_view()
333                    && (!analysis.includes_code() || !matches!(view, Self::Languages))
334            })
335            .partition(|view| analysis.contains(view.implies()))
336    }
337
338    /// Whether this view belongs in `--view full`.
339    ///
340    /// `full` is every *summary* view. `files` is an unbounded enumeration, and putting
341    /// one inside a digest destroys the digest.
342    pub const fn is_summary_view(self) -> bool {
343        !matches!(self, Self::List | Self::Files)
344    }
345}
346
347/// The view axis as its caller wrote it: parsed, and not yet resolved against the
348/// analyzers that will answer it.
349///
350/// Parsing and resolving are two steps because a request that builds its own basis needs
351/// what the named views imply before it can know its analyzers, and `full` and the default
352/// can only be resolved once it does. The grammar runs once either way, so a refusal is
353/// the same whichever step a caller stops at.
354#[derive(Clone, Debug, PartialEq, Eq)]
355pub(crate) enum ViewList {
356    /// The caller named no view, so the analyzers choose ([`ViewSpec::defaults_for`]).
357    Default,
358    /// `full`: every summary view the analyzers can answer.
359    Full,
360    /// The views named, in the caller's order.
361    Named(Vec<ViewSpec>),
362}
363
364impl ViewList {
365    /// Read a comma list of view names, or `full` alone.
366    pub(crate) fn parse(spec: Option<&str>) -> Result<Self, Rejection> {
367        let Some(spec) = spec else {
368            return Ok(Self::Default);
369        };
370        let mut parsed: Vec<ViewSpec> = Vec::new();
371        let mut full_seen = false;
372        for raw in spec.split(',') {
373            let token = raw.trim();
374            if token.is_empty() {
375                return Err(Rejection::new(spec, "empty entry in the list"));
376            }
377            if token.eq_ignore_ascii_case("full") {
378                if full_seen || !parsed.is_empty() {
379                    return Err(Rejection::new("full", ViewSpec::FULL_IS_EXCLUSIVE));
380                }
381                full_seen = true;
382                continue;
383            }
384            if full_seen {
385                return Err(Rejection::new("full", ViewSpec::FULL_IS_EXCLUSIVE));
386            }
387            let view =
388                ViewSpec::parse(token).map_err(|expected| Rejection::new(token, expected))?;
389            if parsed.contains(&view) {
390                return Err(Rejection::new(spec, format!("{token:?} appears more than once")));
391            }
392            parsed.push(view);
393        }
394        Ok(if full_seen { Self::Full } else { Self::Named(parsed) })
395    }
396
397    /// The named views that imply an analyzer, in the caller's order.
398    ///
399    /// Empty for `full` and for the default, which imply nothing: one is the metadata
400    /// digest and the other is chosen *by* the analyzers.
401    pub(crate) fn implying(&self) -> Vec<ViewSpec> {
402        match self {
403            Self::Named(views) => {
404                views.iter().copied().filter(|view| view.implies().is_enabled()).collect()
405            }
406            Self::Default | Self::Full => Vec::new(),
407        }
408    }
409
410    /// Every analyzer the named views imply.
411    pub(crate) fn implies(&self) -> AnalysisSet {
412        self.implying().into_iter().fold(AnalysisSet::NONE, |set, view| set.union(view.implies()))
413    }
414
415    /// The views to render and the ones `full` had to drop, given the analyzers.
416    pub(crate) fn resolve(self, analysis: AnalysisSet) -> (Vec<ViewSpec>, Vec<ViewSpec>) {
417        match self {
418            Self::Default => (ViewSpec::defaults_for(analysis), Vec::new()),
419            Self::Full => ViewSpec::full_report(analysis),
420            Self::Named(views) => (views, Vec::new()),
421        }
422    }
423}
424
425/// What the calling surface calls the knobs a report's diagnostics name.
426///
427/// The same reason `ViewSpec::resolve` and `AnalysisSet::parse_labeled` take a label: a
428/// rule belongs to the library, but the words a caller can act on belong to the surface
429/// they came through. Telling a Python caller to "add --analyze" names a flag that does
430/// not exist in their surface -- the defect that made these messages worth moving here in
431/// the first place, reappearing one door over (fdu-4apt).
432///
433/// The view and analyzer axes both, because both diagnostics name both: the view that
434/// cannot be answered, and the analyzer that would answer it. The two control limits,
435/// because the note about refused `.gitignore` files names the limit that applies them.
436/// The ignored-state selections and the observation switch, because selecting by ignored
437/// state in a scan that reads no `.gitignore` is refused by naming both.
438///
439/// And every other axis a [`RequestError`](crate::query::RequestError) names: the value
440/// grammars of the request model reject a value by naming its axis, and the watch
441/// refusals name the knobs a watch cannot honor, so one type states each rule and each
442/// surface supplies only its words.
443#[derive(Clone, Copy, Debug, PartialEq, Eq)]
444pub struct AxisNames {
445    /// The view axis.
446    pub view: &'static str,
447    /// The output format axis.
448    pub format: &'static str,
449    /// The analyzer axis.
450    pub analyze: &'static str,
451    /// The budget on retained `.gitignore` state.
452    pub control_budget: &'static str,
453    /// The limit on one `.gitignore` line.
454    pub control_line_limit: &'static str,
455    /// The selection of unignored entries only.
456    pub exclude_ignored: &'static str,
457    /// The selection of ignored entries only.
458    pub only_ignored: &'static str,
459    /// The switch that turns `.gitignore` observation off.
460    pub read_controls: &'static str,
461    /// The retention depth of a scan.
462    pub scan_depth: &'static str,
463    /// The switch that keeps a scan on the root's filesystem.
464    pub one_filesystem: &'static str,
465    /// The switch that walks into what a symbolic link points at.
466    pub follow_symlinks: &'static str,
467    /// The patterns an entry must match.
468    pub include: &'static str,
469    /// The inclusive lower bound on modification time.
470    pub modified_since: &'static str,
471    /// The exclusive upper bound on modification time.
472    pub modified_before: &'static str,
473    /// The entry kinds a selection admits.
474    pub kind: &'static str,
475    /// The selection by ignored state, as one axis.
476    pub ignored: &'static str,
477    /// How deep a rendered tree descends.
478    pub depth: &'static str,
479    /// The minimum displayed share of the selected root.
480    pub min_share: &'static str,
481    /// Maximum child rows per directory.
482    pub breadth: &'static str,
483    /// How many rows a view keeps.
484    pub limit: &'static str,
485    /// The ordering key.
486    pub sort: &'static str,
487    /// The size metric.
488    pub size: &'static str,
489    /// The logical-word denominator of a document page.
490    pub words_per_page: &'static str,
491    /// The cache policy.
492    pub cache: &'static str,
493    /// The request to answer from the snapshot alone.
494    pub stale_ok: &'static str,
495    /// The request to repeat the answer as a watch.
496    pub watch: &'static str,
497    /// What joins several settings in one suggestion: flags read as one command line,
498    /// keyword arguments as one call's arguments.
499    pub setting_separator: &'static str,
500}
501
502impl AxisNames {
503    /// How the command line spells them.
504    ///
505    /// `ignored` names the CLI's one `--ignored` value; the two specific selection
506    /// values are available for diagnostics that prescribe one of them.
507    ///
508    /// `follow_symlinks` is the one axis this surface cannot set at all, so it keeps the
509    /// library's name: a request carrying it came from a library or `open` caller, and
510    /// naming a `--follow-symlinks` that does not exist would point them at the wrong door.
511    pub const FLAGS: Self = Self {
512        view: "--view",
513        format: "--format",
514        analyze: "--analyze",
515        control_budget: "--gitignore-budget",
516        control_line_limit: "--gitignore-line-limit",
517        exclude_ignored: "--ignored=exclude",
518        only_ignored: "--ignored=only",
519        read_controls: "--no-gitignore",
520        scan_depth: "--scan-depth",
521        one_filesystem: "--one-filesystem",
522        follow_symlinks: "follow_symlinks",
523        include: "--include",
524        modified_since: "--modified-since",
525        modified_before: "--modified-before",
526        kind: "--kind",
527        ignored: "--ignored",
528        depth: "--depth",
529        min_share: "--min-share",
530        breadth: "--breadth",
531        limit: "--limit",
532        sort: "--sort",
533        size: "--size",
534        words_per_page: "--words-per-page",
535        cache: "--cache",
536        stale_ok: "--stale-ok",
537        watch: "--watch",
538        setting_separator: " ",
539    };
540
541    /// How the library and the Python API spell them, and the default: a `Query` built
542    /// without saying otherwise belongs to a library caller, not to the command line.
543    ///
544    /// `view` singular, matching the label the binding already passes to
545    /// `ViewSpec::resolve`, so every diagnostic about this axis names it one way. It also
546    /// keeps the difference from the command line to the flag dashes alone, which is what
547    /// the parity harness's `surface-label` class checks.
548    ///
549    /// `cache` is the one name that is not a field: the Python parameter is `cache`, but its
550    /// refusal has always said `invalid cache policy`, and this type moved the wording
551    /// without changing it.
552    pub const FIELDS: Self = Self {
553        view: "view",
554        format: "format",
555        analyze: "analyze",
556        control_budget: "control_budget",
557        control_line_limit: "control_line_limit",
558        exclude_ignored: "ignored=exclude",
559        only_ignored: "ignored=only",
560        read_controls: "read_controls",
561        scan_depth: "max_depth",
562        one_filesystem: "one_filesystem",
563        follow_symlinks: "follow_symlinks",
564        include: "include",
565        modified_since: "modified_since",
566        modified_before: "modified_before",
567        kind: "kind",
568        ignored: "ignored",
569        depth: "depth",
570        min_share: "min_share",
571        breadth: "breadth",
572        limit: "limit",
573        sort: "sort",
574        size: "size",
575        words_per_page: "words_per_page",
576        cache: "cache policy",
577        stale_ok: "stale_ok",
578        watch: "watch",
579        setting_separator: ", ",
580    };
581}
582
583impl Default for AxisNames {
584    fn default() -> Self {
585        Self::FIELDS
586    }
587}
588
589/// What a report was asked for.
590#[derive(Clone, Debug)]
591pub struct Query {
592    /// Which entries to consider and how to shape results.
593    pub selection: Selection,
594    /// Which views to report, in the order they were requested.
595    pub views: Vec<ViewSpec>,
596    /// Presentation requested before projecting retained entries.
597    pub format: crate::report_format::Format,
598    /// Views `full` had to drop because the requested analyzers cannot answer them.
599    ///
600    /// Carried so the report can name the omission rather than leave a caller to notice a
601    /// section is missing. It lived in the CLI, which meant only the CLI could tell anyone
602    /// (fdu-x8u6); `ViewSpec::resolve` returns it and this is where it lands.
603    pub omitted_views: Vec<ViewSpec>,
604    /// What the requesting surface calls the axes its diagnostics name.
605    ///
606    /// Carried on the request because that is what knows which door the caller came
607    /// through; the rules themselves stay here and are each stated once.
608    pub axes: &'static AxisNames,
609    /// Fixed logical-word denominator used to derive page equivalents after aggregation.
610    pub words_per_page: u64,
611}
612
613impl Default for Query {
614    fn default() -> Self {
615        Self {
616            selection: Selection::default(),
617            views: Vec::new(),
618            format: crate::report_format::Format::Text,
619            omitted_views: Vec::new(),
620            axes: &AxisNames::FIELDS,
621            words_per_page: crate::query::Request::DEFAULTS.words_per_page,
622        }
623    }
624}
625
626impl Query {
627    /// Whether this view needs the directory hierarchy rather than matching flat rows.
628    pub fn tree_for(&self, view: ViewSpec) -> bool {
629        use crate::report_format::Format;
630        match view {
631            ViewSpec::List => matches!(self.format, Format::Text | Format::Tree),
632            ViewSpec::Tree => !matches!(self.format, Format::Paths | Format::Long),
633            ViewSpec::Files => self.format == Format::Tree,
634            _ => false,
635        }
636    }
637
638    /// Whether this read needs directory candidates and their subtree measurements.
639    pub(crate) fn needs_selection_walk(&self) -> bool {
640        !self.selection.is_unfiltered()
641            || self.views.iter().any(|view| {
642                matches!(view, ViewSpec::List | ViewSpec::Tree | ViewSpec::Files)
643                    && !self.tree_for(*view)
644            })
645    }
646
647    /// The bound to apply for `view`: the caller's if they named one, else the view's own.
648    pub fn limit_for(&self, view: ViewSpec) -> Bound {
649        self.selection.limit.unwrap_or_else(|| {
650            if self.tree_for(view) {
651                ViewSpec::Tree.default_limit()
652            } else if view == ViewSpec::Tree {
653                Bound::All
654            } else {
655                view.default_limit()
656            }
657        })
658    }
659
660    /// The tree depth to apply for `view`, on the same terms as `limit_for`.
661    pub fn depth_for(&self, view: ViewSpec) -> Bound {
662        self.selection.depth.unwrap_or_else(|| {
663            if self.tree_for(view) { ViewSpec::Tree.default_depth() } else { view.default_depth() }
664        })
665    }
666
667    /// The independent per-directory breadth cap.
668    pub fn breadth_for(&self) -> Bound {
669        self.selection.breadth.unwrap_or(Bound::All)
670    }
671
672    /// The minimum root-relative share for tree rows.
673    pub fn min_share_for(&self) -> ShareThreshold {
674        self.selection.min_share.clone().unwrap_or_else(ShareThreshold::one_percent)
675    }
676}
677
678/// Which tier of the freshness ladder produced the index behind a report.
679#[derive(Clone, Copy, PartialEq, Eq, Debug)]
680pub enum ReportSource {
681    /// The tree was walked from scratch.
682    ColdScan,
683    /// A snapshot was loaded and revalidated against the filesystem.
684    WarmRevalidate,
685    /// A snapshot answered without the filesystem being consulted.
686    CacheOnly,
687}
688
689/// One directory's row in a tree view.
690#[derive(Clone, Debug)]
691pub struct TreeNode {
692    /// Path relative to the index root; empty for the root itself.
693    pub path: PathBuf,
694    /// Final path component, or `.` for the root.
695    pub name: String,
696    /// What the entry is.
697    pub kind: EntryKind,
698    /// Whether this entry itself is gitignored, or `None` when its classification is unknown.
699    /// This is independent of the selected subtree's `ignored` tally.
700    pub entry_ignored: Option<bool>,
701    /// Apparent bytes in this subtree.
702    pub bytes: u64,
703    /// Allocated bytes in this subtree.
704    pub allocated: u64,
705    /// Files in this subtree.
706    pub files: u64,
707    /// Directories in this subtree.
708    pub dirs: u64,
709    /// The part of this subtree's tallies that `.gitignore` rules ignore, or `None` when
710    /// governing controls were unobserved or could not be verified.
711    ///
712    /// Counted over the selected entries, like every other tally on the row, so it is zero
713    /// when the selection excludes ignored entries and the whole row when it admits only
714    /// them.
715    pub ignored: Option<IgnoredTally>,
716    /// Newest modification time in this subtree, when it holds any files.
717    pub newest_mtime_ns: Option<i64>,
718    /// Children reported beneath this node.
719    pub children: Vec<TreeNode>,
720    /// Disjoint child subtrees first excluded at this node's display boundary.
721    pub omissions: Vec<TreeOmission>,
722    /// Whether children were withheld by the depth or limit bound.
723    pub truncated: bool,
724}
725
726/// Which independent display bound first removed a tree subtree.
727#[derive(Clone, Copy, Debug, PartialEq, Eq)]
728pub enum TreeOmissionReason {
729    /// Below the root-relative minimum share.
730    Share,
731    /// Beyond the per-directory breadth cap.
732    Breadth,
733    /// Beyond the maximum displayed depth.
734    Depth,
735    /// Beyond the section's total data-row cap.
736    Rows,
737}
738
739impl TreeOmissionReason {
740    /// Stable machine spelling.
741    pub const fn label(self) -> &'static str {
742        match self {
743            Self::Share => "share",
744            Self::Breadth => "breadth",
745            Self::Depth => "depth",
746            Self::Rows => "rows",
747        }
748    }
749}
750
751/// A disjoint group of direct child subtrees omitted by one display bound.
752#[derive(Clone, Debug, PartialEq, Eq)]
753pub struct TreeOmission {
754    /// The first exclusion boundary.
755    pub reason: TreeOmissionReason,
756    /// Direct child roots omitted at this boundary.
757    pub entries: usize,
758    /// Exact recursive regular-file tally, distinct from direct child roots.
759    pub files: Option<u64>,
760    /// Exact apparent remainder when the subtree measurements are complete.
761    pub bytes: Option<u64>,
762    /// Exact allocated remainder when the subtree measurements are complete.
763    pub allocated: Option<u64>,
764    /// Ignored part of the omitted subtrees, when their governing rules are known.
765    pub ignored: Option<IgnoredSize>,
766}
767
768/// Exact ignored byte sizes on a disjoint omitted subtree.
769/// Directory inode usage is not part of either size metric.
770#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
771pub struct IgnoredSize {
772    /// Apparent bytes of ignored regular files.
773    pub bytes: u64,
774    /// Allocated bytes of ignored regular files.
775    pub allocated: u64,
776}
777
778impl IgnoredSize {
779    fn from_tally(tally: IgnoredTally) -> Self {
780        Self { bytes: tally.bytes, allocated: tally.allocated }
781    }
782
783    fn checked_add(self, other: Self) -> Option<Self> {
784        Some(Self {
785            bytes: self.bytes.checked_add(other.bytes)?,
786            allocated: self.allocated.checked_add(other.allocated)?,
787        })
788    }
789}
790
791/// Content not represented by a listed row in a projected tree, shared by every format.
792/// The root gives context; each displayed direct child represents its entire recursive
793/// subtree, even where expansion below that child is bounded. Only the root's immediate
794/// omission boundaries contribute, or the omitted root when no root row is shown.
795/// Unknown or overflowing constituents propagate as `None`.
796#[derive(Clone, Debug, PartialEq, Eq)]
797pub struct TreeRemainder {
798    /// Recursive regular files outside displayed top-level subtrees.
799    pub files: Option<u64>,
800    /// Apparent bytes in hidden subtrees.
801    pub bytes: Option<u64>,
802    /// Allocated bytes in hidden subtrees.
803    pub allocated: Option<u64>,
804    /// Ignored part of the disjoint hidden subtrees, when classification is known.
805    /// Human bar coloring consumes this fact; the machine remainder schema is unchanged.
806    pub ignored: Option<IgnoredSize>,
807    /// Applicable boundaries in stable share, depth, breadth, row order.
808    pub reasons: Vec<TreeOmissionReason>,
809}
810
811impl TreeRemainder {
812    /// Summarize the selected population not represented by displayed top-level rows.
813    pub fn from_tree(root: Option<&TreeNode>, omissions: &[TreeOmission]) -> Option<Self> {
814        let mut result = Self {
815            files: Some(0),
816            bytes: Some(0),
817            allocated: Some(0),
818            ignored: Some(IgnoredSize::default()),
819            reasons: Vec::new(),
820        };
821        let mut add = |omission: &TreeOmission| {
822            result.files = result.files.zip(omission.files).and_then(|(a, b)| a.checked_add(b));
823            result.bytes = result.bytes.zip(omission.bytes).and_then(|(a, b)| a.checked_add(b));
824            result.allocated =
825                result.allocated.zip(omission.allocated).and_then(|(a, b)| a.checked_add(b));
826            result.ignored =
827                result.ignored.zip(omission.ignored).and_then(|(a, b)| a.checked_add(b));
828            if !result.reasons.contains(&omission.reason) {
829                result.reasons.push(omission.reason);
830            }
831        };
832        // A displayed child directory already carries all of its descendants in its
833        // rollup. Omissions below it limit expansion, not the represented population.
834        let boundary = root.map_or(omissions, |root| root.omissions.as_slice());
835        for omission in boundary {
836            add(omission);
837        }
838        result.reasons.sort_by_key(|why| match why {
839            TreeOmissionReason::Share => 0,
840            TreeOmissionReason::Depth => 1,
841            TreeOmissionReason::Breadth => 2,
842            TreeOmissionReason::Rows => 3,
843        });
844        (!result.reasons.is_empty()).then_some(result)
845    }
846}
847
848/// Resolved, independent display bounds for one hierarchical section.
849#[derive(Clone, Debug, PartialEq, Eq)]
850pub struct TreeDisplayLimits {
851    /// Deepest displayed entry; root is depth zero.
852    pub depth: Bound,
853    /// Root-relative minimum share.
854    pub min_share: ShareThreshold,
855    /// Child rows admitted per directory.
856    pub breadth: Bound,
857    /// Data rows admitted in the section, including the root.
858    pub rows: Bound,
859}
860
861impl Drop for TreeNode {
862    /// Release children iteratively.
863    ///
864    /// The derived drop glue recurses once per level, so a deeply nested tree would
865    /// exhaust the stack on release even after every renderer was made iterative — the
866    /// same hazard the index avoids when freeing a subtree. Taking the children out first
867    /// turns that recursion into a loop.
868    fn drop(&mut self) {
869        let mut pending = std::mem::take(&mut self.children);
870        while let Some(mut node) = pending.pop() {
871            pending.extend(std::mem::take(&mut node.children));
872        }
873    }
874}
875
876/// The part of a row's tallies that `.gitignore` rules ignore.
877///
878/// An entry is ignored when a rule matches it or any ancestor directory, as git cannot
879/// re-include a file below an excluded directory. Below a refused `.gitignore`
880/// ([`Report::ignore_rules`]) the split is not exact in either direction; the sizes it
881/// divides are.
882#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
883pub struct IgnoredTally {
884    /// Ignored files.
885    pub files: u64,
886    /// Ignored directories. Always zero on an extension row, which counts files only.
887    pub dirs: u64,
888    /// Apparent bytes across ignored files.
889    pub bytes: u64,
890    /// Allocated bytes across ignored files.
891    pub allocated: u64,
892}
893
894impl IgnoredTally {
895    /// The ignored share of a roll-up: what `all` holds beyond `unignored`.
896    pub(crate) fn between(all: RollUpScalars, unignored: RollUpScalars) -> Self {
897        Self {
898            files: all.files.saturating_sub(unignored.files),
899            dirs: all.dirs.saturating_sub(unignored.dirs),
900            bytes: all.bytes.saturating_sub(unignored.bytes),
901            allocated: all.allocated.saturating_sub(unignored.allocated),
902        }
903    }
904
905    fn add(&mut self, other: Self) {
906        self.files = self.files.saturating_add(other.files);
907        self.dirs = self.dirs.saturating_add(other.dirs);
908        self.bytes = self.bytes.saturating_add(other.bytes);
909        self.allocated = self.allocated.saturating_add(other.allocated);
910    }
911}
912
913/// One extension's row in a types view.
914#[derive(Clone, Debug)]
915pub struct TypeRow {
916    /// The derived extension, including its leading dot.
917    pub extension: String,
918    /// Files with this extension.
919    pub files: u64,
920    /// Apparent bytes across those files.
921    pub bytes: u64,
922    /// Allocated bytes across those files.
923    pub allocated: u64,
924    /// The ignored part of this row, or `None` when governing controls are unknown.
925    pub ignored: Option<IgnoredTally>,
926}
927
928/// Dimension used by a generic metric-summary section.
929#[derive(Clone, Copy, PartialEq, Eq, Debug)]
930pub enum MetricGroup {
931    /// Stable detected file type or language ID.
932    Type,
933    /// Broad code/prose/markup/data/binary family.
934    Family,
935}
936
937/// Exact share represented as an integer fraction.
938///
939/// A grouped section's rows partition its denominator: with no share filter or row bound,
940/// their numerators sum to it exactly.
941#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
942pub struct MetricShare {
943    /// This row's value in the section's share metric. On a section's total row it is the
944    /// denominator itself, the sum of the rows, which for document words can differ from
945    /// the total row's own pooled `document_words`.
946    pub numerator: u64,
947    /// Sum of every row's numerator before the share filter and display truncation.
948    pub denominator: u64,
949}
950
951/// Metric used as the numerator and denominator of grouped percentages.
952#[derive(Clone, Copy, PartialEq, Eq, Debug)]
953pub enum ShareMetric {
954    /// Apparent file bytes selected by the query.
955    ApparentBytes,
956    /// Allocated filesystem bytes selected by the query.
957    AllocatedBytes,
958    /// Standard code lines from `code-sloc-v1`.
959    CodeLines,
960    /// Raw or reader-visible normalized document words, selected by analysis depth.
961    DocumentWords,
962    /// Whitespace-delimited words from the shared lines unit.
963    RawWords,
964}
965
966impl ShareMetric {
967    /// Stable machine label.
968    pub const fn as_str(self) -> &'static str {
969        match self {
970            Self::ApparentBytes => "apparent_bytes",
971            Self::AllocatedBytes => "allocated_bytes",
972            Self::CodeLines => "code_lines",
973            Self::DocumentWords => "document_words",
974            Self::RawWords => "raw_words",
975        }
976    }
977}
978
979/// One stable group in a metric-summary section.
980#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
981pub struct ReportMetricValues {
982    /// Physical lines, present with the lines unit.
983    pub physical_lines: Option<u64>,
984    /// Blank lines, present with the lines unit.
985    pub blank_lines: Option<u64>,
986    /// Nonblank lines, present with the lines unit.
987    pub nonblank_lines: Option<u64>,
988    /// Raw words, present with the lines unit.
989    pub raw_words: Option<u64>,
990    /// Code lines, present with the code unit.
991    pub code_lines: Option<u64>,
992    /// Comment lines, present with the code unit.
993    pub comment_lines: Option<u64>,
994    /// Code-analyzer blank lines, present with the code unit.
995    pub code_blank_lines: Option<u64>,
996    /// Logical words, present with the words unit.
997    pub logical_words: Option<u64>,
998    /// Paragraphs, present with the words unit.
999    pub paragraphs: Option<u64>,
1000    /// Reader-visible words, present with the words unit.
1001    pub visible_words: Option<u64>,
1002    /// Reader-visible logical words, present with the words unit.
1003    pub visible_logical_words: Option<u64>,
1004    /// Query-selected document words, present with the words unit.
1005    pub document_words: Option<u64>,
1006}
1007
1008impl ReportMetricValues {
1009    fn for_analysis(analysis: AnalysisSet) -> Self {
1010        let lines = analysis.is_enabled().then_some(0);
1011        let code = analysis.includes_code().then_some(0);
1012        let words = analysis.includes_words().then_some(0);
1013        Self {
1014            physical_lines: lines,
1015            blank_lines: lines,
1016            nonblank_lines: lines,
1017            raw_words: lines,
1018            code_lines: code,
1019            comment_lines: code,
1020            code_blank_lines: code,
1021            logical_words: words,
1022            paragraphs: words,
1023            visible_words: words,
1024            visible_logical_words: words,
1025            document_words: words,
1026        }
1027    }
1028
1029    fn add_assign(&mut self, other: &Self) {
1030        add_optional(&mut self.physical_lines, other.physical_lines);
1031        add_optional(&mut self.blank_lines, other.blank_lines);
1032        add_optional(&mut self.nonblank_lines, other.nonblank_lines);
1033        add_optional(&mut self.raw_words, other.raw_words);
1034        add_optional(&mut self.code_lines, other.code_lines);
1035        add_optional(&mut self.comment_lines, other.comment_lines);
1036        add_optional(&mut self.code_blank_lines, other.code_blank_lines);
1037        add_optional(&mut self.paragraphs, other.paragraphs);
1038        add_optional(&mut self.visible_words, other.visible_words);
1039    }
1040}
1041
1042fn add_optional(total: &mut Option<u64>, value: Option<u64>) {
1043    if let (Some(total), Some(value)) = (total, value) {
1044        *total = total.saturating_add(value);
1045    }
1046}
1047
1048/// One stable group in a metric-summary section.
1049#[derive(Clone, Debug)]
1050pub struct MetricRow {
1051    /// Analyzer units requested for this row.
1052    pub analysis: AnalysisSet,
1053    /// Stable type or family label.
1054    pub id: String,
1055    /// Broad family for type-grouped rows.
1056    pub family: ContentFamily,
1057    /// Matching regular files.
1058    pub files: u64,
1059    /// Apparent bytes.
1060    pub bytes: u64,
1061    /// Allocated bytes.
1062    pub allocated: u64,
1063    /// Files whose requested metrics completed.
1064    pub analyzed_files: u64,
1065    /// Additive content metric slots.
1066    pub metrics: ReportMetricValues,
1067    /// Additive sufficient statistics behind `logical_words`.
1068    pub(crate) logical_word_stats: LogicalWordStats,
1069    /// Additive sufficient statistics behind `visible_logical_words`.
1070    pub(crate) visible_logical_word_stats: LogicalWordStats,
1071    /// Query-selected raw document words before normalization.
1072    pub document_raw_words: u64,
1073    /// Additive sufficient statistics for the query-selected document projection.
1074    pub document_word_stats: LogicalWordStats,
1075    /// Analyzed files that supplied normalized document statistics.
1076    pub document_metric_files: u64,
1077    /// Explicit content-analysis outcomes.
1078    pub coverage: BTreeMap<CoverageReason, u64>,
1079    /// Lines-unit outcomes.
1080    pub lines_coverage: BTreeMap<CoverageReason, u64>,
1081    /// Code-unit outcomes when requested.
1082    pub code_coverage: Option<BTreeMap<CoverageReason, u64>>,
1083    /// Words-unit outcomes when requested.
1084    pub words_coverage: Option<BTreeMap<CoverageReason, u64>>,
1085    /// Files by the classification tier that established their type.
1086    pub detection_sources: BTreeMap<DetectionSource, u64>,
1087    /// Files by classification confidence.
1088    pub detection_confidence: BTreeMap<DetectionConfidence, u64>,
1089    /// Files carrying a bounded generated-file marker.
1090    pub generated_files: u64,
1091    /// Files below a conventional vendored path.
1092    pub vendored_files: u64,
1093    /// Files below a conventional documentation path or basename.
1094    pub documentation_files: u64,
1095    /// Exact share in the report's selected size metric.
1096    pub share: MetricShare,
1097}
1098
1099impl MetricRow {
1100    /// Return one registry metric when its owning analyzer unit was requested.
1101    pub fn metric_value(&self, metric: &MetricDef) -> Option<u64> {
1102        if !self.analysis.contains(metric.owner) {
1103            return None;
1104        }
1105        match metric.name {
1106            "physical_lines" => self.metrics.physical_lines,
1107            "blank_lines" => self.metrics.blank_lines,
1108            "nonblank_lines" => self.metrics.nonblank_lines,
1109            "raw_words" => self.metrics.raw_words,
1110            "code_lines" => self.metrics.code_lines,
1111            "comment_lines" => self.metrics.comment_lines,
1112            "code_blank_lines" => self.metrics.code_blank_lines,
1113            "logical_words" => self.metrics.logical_words,
1114            "paragraphs" => self.metrics.paragraphs,
1115            "visible_words" => self.metrics.visible_words,
1116            "visible_logical_words" => self.metrics.visible_logical_words,
1117            "document_words" => self.metrics.document_words,
1118            _ => None,
1119        }
1120    }
1121
1122    fn finish_derived_metrics(&mut self) {
1123        if self.analysis.includes_words() {
1124            self.metrics.logical_words = Some(self.logical_word_stats.logical_words());
1125            self.metrics.visible_logical_words =
1126                Some(self.visible_logical_word_stats.logical_words());
1127            self.metrics.document_words = Some(self.document_word_stats.logical_words());
1128        }
1129    }
1130}
1131
1132/// Totals and grouped rows for types, families, languages, or documents.
1133#[derive(Clone, Debug)]
1134pub struct MetricSummary {
1135    /// Grouping dimension.
1136    pub group: MetricGroup,
1137    /// Totals across every row before display truncation.
1138    ///
1139    /// Logical, visible logical, and document words are derived from the pooled
1140    /// statistics, so they can differ from the sum of the rows' values. The total's share
1141    /// is the rows' sum over itself, the denominator every row's share uses.
1142    pub total: MetricRow,
1143    /// Sorted, display-bounded rows.
1144    pub rows: Vec<MetricRow>,
1145    /// Rows before the bound was applied.
1146    pub total_rows: usize,
1147    /// Rows removed by the explicit minimum-share filter before the row bound.
1148    pub share_omitted: usize,
1149    /// Metric used for every row's exact share.
1150    pub share_metric: ShareMetric,
1151    /// Logical words per derived page.
1152    pub words_per_page: u64,
1153}
1154
1155/// Code measurements for one selected file population.
1156#[derive(Clone, Debug, Default)]
1157pub struct CodeTally {
1158    /// Code-family regular files, including files the analyzer could not measure.
1159    pub source_files: u64,
1160    /// Files with a successful code result.
1161    pub analyzed_files: u64,
1162    /// Sum of successful code results; coverage identifies unavailable contributions.
1163    pub metrics: CodeMetrics,
1164    /// Outcome counts for records that reached the code analyzer.
1165    pub coverage: BTreeMap<CoverageReason, u64>,
1166    /// Selected source files without a retained analyzer record.
1167    pub missing_records: u64,
1168}
1169
1170impl CodeTally {
1171    fn add_file(&mut self, record: Option<&crate::content::FileAnalysis>) {
1172        self.source_files = self.source_files.saturating_add(1);
1173        match record.and_then(|record| record.code) {
1174            Some(outcome) => {
1175                *self.coverage.entry(outcome.coverage()).or_default() += 1;
1176                if let Some(value) = outcome.value() {
1177                    self.analyzed_files = self.analyzed_files.saturating_add(1);
1178                    self.metrics.code_lines =
1179                        self.metrics.code_lines.saturating_add(value.code_lines);
1180                    self.metrics.comment_lines =
1181                        self.metrics.comment_lines.saturating_add(value.comment_lines);
1182                    self.metrics.code_blank_lines =
1183                        self.metrics.code_blank_lines.saturating_add(value.code_blank_lines);
1184                }
1185            }
1186            None => self.missing_records = self.missing_records.saturating_add(1),
1187        }
1188    }
1189}
1190
1191/// Complete measured contribution of one detected code language.
1192#[derive(Clone, Debug)]
1193pub struct CodeLanguageRow {
1194    /// Stable file-type ID.
1195    pub language: String,
1196    /// All selected files in this language.
1197    pub selected: CodeTally,
1198    /// Selected files known to be non-ignored, when classification was observed.
1199    pub non_ignored: Option<CodeTally>,
1200    /// Selected files known to be ignored, when classification was observed.
1201    pub ignored: Option<CodeTally>,
1202    /// Selected files whose ignore classification is unknown.
1203    pub unknown: CodeTally,
1204    /// Share of measured code lines in the selected population.
1205    pub share: MetricShare,
1206}
1207
1208/// Code-first projection with explicit coverage and display omissions.
1209#[derive(Clone, Debug)]
1210pub struct CodeOverview {
1211    /// Selected ignored population for this answer.
1212    pub population: IgnoredEntries,
1213    /// Totals for the selected population before display bounds.
1214    pub selected: CodeTally,
1215    /// Known non-ignored contribution when classification was observed.
1216    pub non_ignored: Option<CodeTally>,
1217    /// Known ignored contribution when classification was observed.
1218    pub ignored: Option<CodeTally>,
1219    /// Contribution whose ignore classification could not be established.
1220    pub unknown: CodeTally,
1221    /// Selected regular files whose type cascade did not establish a family.
1222    pub unclassified_files: u64,
1223    /// Languages with at least one analyzed source file.
1224    pub analyzed_languages: u64,
1225    /// Language rows eligible before the display row bound.
1226    pub total_languages: usize,
1227    /// Rows removed by the explicit minimum-share filter before the row bound.
1228    pub share_omitted: usize,
1229    /// Detected code languages ordered by the resolved sort and stable name ties.
1230    pub languages: Vec<CodeLanguageRow>,
1231    /// Denominator of language shares, always measured selected code lines.
1232    pub share_metric: ShareMetric,
1233}
1234
1235/// Analyzer identity attached to a content-capable report.
1236#[derive(Clone, Debug)]
1237pub struct ContentReportMetadata {
1238    /// Requested analysis profile.
1239    pub profile: AnalysisSet,
1240    /// Type-rule, option, and analyzer dialect identity.
1241    pub provenance: ContentProvenance,
1242}
1243
1244/// One matching entry in a flat view. Directory size and mtime describe its subtree
1245/// after exclusions; other entries carry their own metadata. Nested rows may overlap.
1246#[derive(Clone, Debug)]
1247pub struct FileRow {
1248    /// Path relative to the index root.
1249    pub path: PathBuf,
1250    /// What the entry is.
1251    pub kind: EntryKind,
1252    /// Apparent bytes.
1253    pub bytes: u64,
1254    /// Allocated bytes.
1255    pub allocated: u64,
1256    /// Modification time in nanoseconds since the Unix epoch.
1257    pub mtime_ns: i64,
1258    /// Descendant regular files for a directory; absent for other entry kinds.
1259    pub files: Option<u64>,
1260    /// Descendant directories, excluding the matching root; absent for other kinds.
1261    pub dirs: Option<u64>,
1262    /// Whether a directory's eligible subtree was listed in full, so its bytes, counts,
1263    /// and modification time are exact; `Some(false)` makes them lower bounds and its age
1264    /// unknown. Absent for other kinds.
1265    pub complete: Option<bool>,
1266    /// Signed nanoseconds since modification at the request's reference instant, or
1267    /// `None` when the reference is unrepresentable or the subtree is incomplete.
1268    pub age_ns: Option<i128>,
1269    /// Whether `.gitignore` rules ignore this entry, or `None` when its governing rules
1270    /// were unobserved or could not be verified.
1271    pub ignored: Option<bool>,
1272    /// Value of the requested numeric metric used for sorting, when measured.
1273    pub sort_value: Option<u64>,
1274    /// Existing type and heuristic evidence for regular files.
1275    pub classification: Option<ContentDetection>,
1276}
1277
1278/// The aggregate row of a summary view.
1279#[derive(Clone, Copy, Debug, Default)]
1280pub struct SummaryRow {
1281    /// Files selected.
1282    pub files: u64,
1283    /// Directories selected.
1284    pub dirs: u64,
1285    /// Apparent bytes.
1286    pub bytes: u64,
1287    /// Allocated bytes.
1288    pub allocated: u64,
1289    /// The ignored part of what was selected, or `None` when governing rules were
1290    /// unobserved or could not be verified for a contributing entry.
1291    pub ignored: Option<IgnoredTally>,
1292    /// Newest modification time, when anything was selected.
1293    pub newest_mtime_ns: Option<i64>,
1294}
1295
1296/// One view's results.
1297#[derive(Clone, Debug)]
1298pub enum Section {
1299    /// A code-first overview with selected totals and bounded language rows.
1300    Code(Box<CodeOverview>),
1301    /// A tree view.
1302    Tree {
1303        /// The view whose directory hierarchy is shown.
1304        view: ViewSpec,
1305        /// Bounds resolved before projection.
1306        limits: TreeDisplayLimits,
1307        /// The bounded directory roll-ups.
1308        root: Option<Box<TreeNode>>,
1309        /// The omitted root when the section row limit is zero.
1310        omissions: Vec<TreeOmission>,
1311    },
1312    /// A raw-extension view.
1313    Extensions {
1314        /// The rows, already sorted and bounded.
1315        rows: Vec<TypeRow>,
1316        /// Rows before the bound was applied.
1317        total: usize,
1318        /// Rows removed by the explicit minimum-share filter before the row bound.
1319        share_omitted: usize,
1320    },
1321    /// A generic type/family content summary.
1322    Metrics {
1323        /// Requested preset that selected grouping and family filters.
1324        view: ViewSpec,
1325        /// Generic grouped metrics.
1326        summary: Box<MetricSummary>,
1327    },
1328    /// A flat listing: `files`, or one of its bounded presets.
1329    ///
1330    /// Carries the view for the same reason `Metrics` does — three views share this shape
1331    /// and a reader has to be told which one produced the rows.
1332    Files {
1333        /// Which of `files`, `largest`, or `recent` produced these rows.
1334        view: ViewSpec,
1335        /// The rows, already sorted and bounded for that view.
1336        rows: Vec<FileRow>,
1337        /// Rows before the bound was applied.
1338        ///
1339        /// Reported so a bound can never be silent: twenty rows of 192,871 look complete
1340        /// unless the report says otherwise, and a consumer reading the machine format
1341        /// has no other way to tell.
1342        total: usize,
1343    },
1344    /// A summary view.
1345    Summary(SummaryRow),
1346}
1347
1348impl Section {
1349    /// Which view produced this section.
1350    pub fn view(&self) -> ViewSpec {
1351        match self {
1352            Self::Code(_) => ViewSpec::Code,
1353            Self::Extensions { .. } => ViewSpec::Extensions,
1354            Self::Tree { view, .. } | Self::Metrics { view, .. } | Self::Files { view, .. } => {
1355                *view
1356            }
1357            Self::Summary(_) => ViewSpec::Summary,
1358        }
1359    }
1360}
1361
1362/// A rendered answer: provenance, plus one section per requested view.
1363#[derive(Clone, Debug)]
1364pub struct Report {
1365    /// Instant used for modification age, in epoch nanoseconds; unknown outside i64.
1366    pub age_reference_ns: Option<i64>,
1367    /// Requested presentation; Text resolves from each section projection.
1368    pub format: crate::report_format::Format,
1369    /// Completeness and bounded failure detail for this answer.
1370    pub status: TreeStatus,
1371    /// Source, currency, and timing for this answer and its retained tiers.
1372    pub provenance: ReportProvenance,
1373    /// The semantic scan scope represented by this report.
1374    ///
1375    /// A projected cache load constructs its index directly in the requested controls-off
1376    /// scope, so every report route reads this value from the same requested-scope index.
1377    pub scope: ScanScope,
1378    /// Analyzer units requested for this answer.
1379    pub requested_analysis: AnalysisSet,
1380    /// Resolved views requested and answerable by this analyzer set.
1381    pub requested_views: Vec<ViewSpec>,
1382    /// Resolved views requested but unavailable from this analyzer set.
1383    pub omitted_views: Vec<ViewSpec>,
1384    /// Absolute path of the indexed root.
1385    pub root: PathBuf,
1386    /// Remarks about the report itself, in the order a renderer should print them.
1387    ///
1388    /// Facts about what was asked for and what could be answered -- not telemetry about
1389    /// the run, which the schema deliberately excludes. Deliberately not serialised: a
1390    /// machine consumer reads the omission from which sections are absent, and adding a
1391    /// field to the envelope would be a schema change for something only humans read.
1392    pub notes: Vec<String>,
1393    /// Actionable suggestions, rendered once after factual notes; excluded from wire data.
1394    pub tips: Vec<String>,
1395    /// Surface vocabulary for actionable display-bound suggestions.
1396    pub axes: &'static AxisNames,
1397    /// Which size metric this report answers in.
1398    ///
1399    /// Carried on the report so a renderer shows the same number the ordering used;
1400    /// printing apparent bytes beside an allocated-bytes ranking looks like a sorting
1401    /// bug and is worse than either metric alone.
1402    pub size: SizeMetric,
1403    /// Registered numeric metric used for sorting, when selected.
1404    pub sort_metric: Option<&'static str>,
1405    /// Analyzer identity when sparse content records are present.
1406    pub analysis: Option<ContentReportMetadata>,
1407    /// Which entries the rows count by `.gitignore` classification.
1408    ///
1409    /// Carried for the renderer, like [`Self::size`], and not serialised: a row whose
1410    /// selection admits only ignored entries is wholly ignored, so text leaves the ignored
1411    /// share off rather than repeat the size beside it.
1412    pub ignored_entries: IgnoredEntries,
1413    /// Whether ignore classification applies every `.gitignore` in scope, serialised as
1414    /// `ignore_rules`.
1415    ///
1416    /// Not operational completeness: a refused control file leaves [`TreeStatus::complete`]
1417    /// true and every size exact, and costs only the ignored and unignored split below
1418    /// it. [`Self::notes`] names the directories and the knob that applies them.
1419    pub ignore_rules: ControlCoverage,
1420    /// One section per requested view, in request order.
1421    pub sections: Vec<Section>,
1422}
1423
1424/// Remarks a report makes about itself.
1425///
1426/// Only what the request, the analyzers its basis holds, the resolved views, and the
1427/// index's coverage can establish. The CLI also prints a note quoting how many bytes
1428/// analysis read, which is walk telemetry the report envelope does not carry, so that one
1429/// stays with the performance footer where the rest of the run's telemetry lives.
1430///
1431/// Two remarks relate the analyzers to the views, one for each direction a request can
1432/// leave them unmatched. `full` implies nothing, so it names the views it skipped and the
1433/// one value of the analyzer axis that includes them alongside what already ran. And
1434/// analysis no selected view displays -- warming the sidecar is a supported use, so this
1435/// is a note rather than an error -- is named analyzer by analyzer against
1436/// [`ViewSpec::shows`], with a metric sort counting as a display of its analyzer. When no
1437/// selected view shows any analysis, the tip names the views [`ViewSpec::defaults_for`]
1438/// the analyzers; when some is shown, it keeps the caller's views and adds the ones that
1439/// show the rest, so following it never drops what the caller already sees.
1440pub(crate) fn display_notes(
1441    query: &Query,
1442    content: AnalysisSet,
1443    ignore_rules: &ControlCoverage,
1444) -> (Vec<String>, Vec<String>) {
1445    let mut notes = Vec::new();
1446    let mut tips = Vec::new();
1447    let labels = |views: &[ViewSpec]| views.iter().map(|view| view.label()).collect::<Vec<_>>();
1448    if !query.omitted_views.is_empty() {
1449        let needed = query
1450            .omitted_views
1451            .iter()
1452            .fold(AnalysisSet::NONE, |set, view| set.union(view.implies()));
1453        let held = content.labels();
1454        let missing: Vec<&str> =
1455            needed.named().into_iter().filter(|name| !held.contains(name)).collect();
1456        let without = if content.is_enabled() {
1457            format!("{} analysis", missing.join(" and "))
1458        } else {
1459            "analysis".to_owned()
1460        };
1461        notes.push(format!(
1462            "note: full omits {} without {without}",
1463            labels(&query.omitted_views).join(", ")
1464        ));
1465        tips.push(format!(
1466            "tip: include them: {} {}",
1467            query.axes.analyze,
1468            content.union(needed).request_label()
1469        ));
1470    }
1471    if let Some((note, tip)) = refused_controls_note(ignore_rules, query.axes) {
1472        notes.push(note);
1473        tips.extend(tip);
1474    }
1475    // A metric sort uses its analyzer even where no column shows it.
1476    let ranked = match query.selection.sort {
1477        Some(SortKey::Metric(name)) => crate::content::METRICS
1478            .iter()
1479            .find(|metric| metric.name == name)
1480            .map_or(AnalysisSet::NONE, |metric| metric.owner),
1481        _ => AnalysisSet::NONE,
1482    };
1483    let shown = query.views.iter().fold(ranked, |set, view| set.union(view.shows()));
1484    let unshown = unshown_analysis(content, shown);
1485    if unshown.is_enabled() {
1486        notes.push(format!(
1487            "note: {} analysis not shown by {}",
1488            unshown.named().join(" and "),
1489            labels(&query.views).join(", ")
1490        ));
1491        let mut views = if shown.is_enabled() { query.views.clone() } else { Vec::new() };
1492        views.extend(ViewSpec::defaults_for(unshown));
1493        tips.push(format!("tip: show it: {} {}", query.axes.view, labels(&views).join(",")));
1494    }
1495    (notes, tips)
1496}
1497
1498/// The analyzers in `content` that `shown` does not display, as a set a caller could
1499/// request.
1500///
1501/// Every view that shows any analysis shows the shared line pass, so line counts go unshown
1502/// only when nothing is shown at all, and then the whole set is.
1503fn unshown_analysis(content: AnalysisSet, shown: AnalysisSet) -> AnalysisSet {
1504    if !shown.is_enabled() {
1505        return content;
1506    }
1507    let mut unshown = AnalysisSet::NONE;
1508    if content.includes_code() && !shown.includes_code() {
1509        unshown = unshown.with_code();
1510    }
1511    if content.includes_words() && !shown.includes_words() {
1512        unshown = unshown.with_words();
1513    }
1514    unshown
1515}
1516
1517/// Directories a refused-controls note names before it counts the rest.
1518const REFUSED_DIRECTORIES_NAMED: usize = 5;
1519
1520/// Say which `.gitignore` files were not applied, why, where, and what applies them.
1521///
1522/// The truncation states itself: the note names a few directories and counts the rest,
1523/// and a structured report lists up to [`crate::MAX_RETAINED_ISSUES`] beside the exact
1524/// count. Each limit is named with the refusals it caused only when every refusal is
1525/// listed; otherwise the note names every limit that could have refused an unlisted file.
1526/// The remedy raises exactly the limits it named, each by the name the requesting surface
1527/// uses, so lifting one never reads as lifting the other.
1528fn refused_controls_note(
1529    ignore_rules: &ControlCoverage,
1530    axes: &AxisNames,
1531) -> Option<(String, Option<String>)> {
1532    use crate::control::ControlRefusalReason::{Budget, LineLimit};
1533
1534    let ControlCoverage::Observed(observed) = ignore_rules else {
1535        return None;
1536    };
1537    if observed.is_complete() {
1538        return None;
1539    }
1540    let every_listed = observed.lists_every_refusal();
1541    let listed =
1542        |reason| observed.refusals.iter().filter(|refusal| refusal.reason == reason).count();
1543    // A listed reason certainly fired. When the list is truncated, a bounded limit may also
1544    // have refused an unlisted file; an unbounded one refuses nothing.
1545    let fired: Vec<_> = [Budget, LineLimit]
1546        .into_iter()
1547        .filter(|reason| {
1548            listed(*reason) > 0 || (!every_listed && observed.limits.limit_for(*reason).is_some())
1549        })
1550        .collect();
1551    let size = |reason| {
1552        observed.limits.limit_for(reason).map(|bytes| {
1553            crate::report_format::human_bytes(u64::try_from(bytes).unwrap_or(u64::MAX))
1554        })
1555    };
1556    // A refusal recorded under an unbounded limit names the limit without a size, never a
1557    // zero one.
1558    let over = |reason| {
1559        let (lead, noun) = match reason {
1560            Budget => ("over", "ignore-rule budget"),
1561            LineLimit => ("with a line over", "line limit"),
1562        };
1563        size(reason).map_or_else(
1564            || format!("{lead} the {noun}"),
1565            |size| format!("{lead} the {size} {noun}"),
1566        )
1567    };
1568    let why = if every_listed {
1569        let parts: Vec<String> =
1570            fired.iter().map(|reason| format!("{} {}", listed(*reason), over(*reason))).collect();
1571        parts.join(", ")
1572    } else {
1573        let parts: Vec<String> = fired.iter().map(|reason| over(*reason)).collect();
1574        parts.join(" or ")
1575    };
1576
1577    let shown = observed.refusals.len().min(REFUSED_DIRECTORIES_NAMED);
1578    let mut directories: Vec<String> = observed.refusals[..shown]
1579        .iter()
1580        .map(|refusal| match refusal.path.parent() {
1581            Some(parent) if !parent.as_os_str().is_empty() => parent.display().to_string(),
1582            _ => ".".to_string(),
1583        })
1584        .collect();
1585    let unnamed = observed.refused.saturating_sub(u64::try_from(shown).unwrap_or(u64::MAX));
1586    if unnamed > 0 {
1587        directories.push(format!("{} more", crate::report_format::human_count(unnamed)));
1588    }
1589
1590    // Only a bounded limit can be raised.
1591    let raises: Vec<String> = fired
1592        .iter()
1593        .filter_map(|reason| {
1594            let knob = match reason {
1595                Budget => axes.control_budget,
1596                LineLimit => axes.control_line_limit,
1597            };
1598            size(*reason).map(|size| format!("{knob} above {size}"))
1599        })
1600        .collect();
1601    let remedy = match raises.as_slice() {
1602        [] => None,
1603        [raise] => {
1604            Some(format!("tip: apply refused ignore files: raise {raise}, or set it to all"))
1605        }
1606        raises => Some(format!(
1607            "tip: apply refused ignore files: raise {}, or set them to all",
1608            raises.join(" and ")
1609        )),
1610    };
1611    let files = crate::report_format::human_count(observed.refused);
1612    let noun = if observed.refused == 1 { "file" } else { "files" };
1613    Some((
1614        format!(
1615            "note: ignore classification incomplete: {files} ignore {noun} not applied ({why}); affected: {}",
1616            directories.join(", ")
1617        ),
1618        remedy,
1619    ))
1620}
1621
1622/// Build a report from an index.
1623///
1624/// Pure: the same index, request, and provenance always produce the same report, and
1625/// nothing here reads the filesystem or mutates the index.
1626///
1627/// # Errors
1628///
1629/// [`Error::InvalidRequest`](crate::Error::InvalidRequest) when this index cannot answer
1630/// the request: it holds another analyzer set than the read asks for, a view needs content
1631/// nothing analyzed, or the request selects by ignored state
1632/// ([`Selection::ignored`]) over an index that read no `.gitignore` -- which can say of no
1633/// entry that it is ignored or that it is not, so the request is refused rather than
1634/// answered with every entry or none.
1635pub fn report(
1636    index: &Index,
1637    request: &Request,
1638    generated_at: std::time::SystemTime,
1639) -> crate::Result<Report> {
1640    report_in(index, request, generated_at, NameIdentity::Native)
1641}
1642
1643/// [`report`], with the selection evaluated against the named spelling of each path.
1644///
1645/// A one-shot report matches native names; an opened-root read matches portable ones, so
1646/// its report projection agrees with its flat and aggregate projections over one query.
1647pub(crate) fn report_in(
1648    index: &Index,
1649    request: &Request,
1650    generated_at: std::time::SystemTime,
1651    identity: NameIdentity,
1652) -> crate::Result<Report> {
1653    // What this index holds is what it can be read for. Every surface validates before it
1654    // scans, in the vocabulary its own caller uses; this is the library path, and the last
1655    // one, so nothing produces an answer from an unvalidated request.
1656    request.validate_read(&Basis::held_by(index)).map_err(crate::Error::InvalidRequest)?;
1657
1658    let query = &request.query;
1659    let content = request.basis.content;
1660    // Share subtree measurements between selection predicates and partial-tree proof.
1661    // Complete unfiltered metadata reports keep their retained-rollup fast path.
1662    let needs_walk = query.needs_selection_walk();
1663    let needs_tree_measurements = query.views.iter().any(|view| query.tree_for(*view))
1664        && !query.min_share_for().admits(0, 1)
1665        && (index.state().coverage != crate::Coverage::Complete
1666            || index.scope().max_depth.is_some());
1667    let directories = (needs_tree_measurements
1668        || (needs_walk
1669            && (query.selection.kinds.is_empty()
1670                || query.selection.kinds.contains(&EntryKind::Dir))))
1671    .then(|| query_subtrees::measure(index, &query.selection, identity));
1672    // One traversal serves every filtered view in the request.
1673    let walked = needs_walk.then(|| walk(index, &query.selection, identity, directories.as_ref()));
1674    let tree_measurements = directories.as_ref().filter(|_| needs_tree_measurements);
1675    // Unfiltered metric and file views share one `FileRow` walk only when more than one
1676    // section consumes it. A single section keeps ownership of its one traversal, so a
1677    // bounded file view does not clone every path before sorting and truncating it.
1678    // Summary, Tree, and Extensions keep roll-ups when unfiltered and do not consume rows.
1679    let row_consumers =
1680        query.views.iter().copied().filter(|view| needs_unfiltered_entry_rows(*view)).count();
1681    let unfiltered_rows = (walked.is_none() && row_consumers > 1).then(|| every_entry(index));
1682    let metric_consumers =
1683        query.views.iter().copied().filter(|view| needs_metric_resolution(*view)).count();
1684    // Every view that needs metric resolution also needs unfiltered entry rows, so more
1685    // than one metric consumer means the rows above were built.
1686    let mut shared_metric_summaries = (walked.is_none() && metric_consumers > 1).then(|| {
1687        metric_summaries(
1688            &query.views,
1689            index,
1690            query,
1691            content,
1692            unfiltered_rows
1693                .as_deref()
1694                .expect("multiple metric views share their unfiltered entry rows"),
1695        )
1696    });
1697
1698    let mut sections: Vec<Section> = query
1699        .views
1700        .iter()
1701        .enumerate()
1702        .map(|(position, view)| {
1703            let shared_metric_summary =
1704                shared_metric_summaries.as_mut().and_then(|summaries| summaries[position].take());
1705            if let Some(summary) = shared_metric_summary {
1706                return Section::Metrics { view: *view, summary: Box::new(summary) };
1707            }
1708            build_section(
1709                *view,
1710                index,
1711                query,
1712                content,
1713                walked.as_ref(),
1714                unfiltered_rows.as_deref(),
1715                tree_measurements,
1716            )
1717        })
1718        .collect();
1719
1720    let age_reference_ns = crate::query::system_time_to_nanos(request.now);
1721    for section in &mut sections {
1722        if let Section::Files { rows, .. } = section {
1723            for row in rows {
1724                // An incomplete subtree's mtime is a lower bound, and a lower-bound
1725                // maximum is not an age: the activity that would make the directory
1726                // younger may sit in the part that was never listed.
1727                row.age_ns = match row.complete {
1728                    Some(false) => None,
1729                    Some(true) | None => {
1730                        age_reference_ns.map(|now| i128::from(now) - i128::from(row.mtime_ns))
1731                    }
1732                };
1733            }
1734        }
1735    }
1736    let ignore_rules = index.control_coverage();
1737    let (mut notes, mut tips) = display_notes(query, content, &ignore_rules);
1738    if content.includes_words() {
1739        // Of the files the report's views show, not of every record the index holds: a
1740        // selection that leaves a Markdown file out says nothing of it. Each metric view's
1741        // total counts the selection before its rows are bounded, and every view that
1742        // groups Markdown counts all of it, so the largest total is the count; a sum would
1743        // count one file once per view.
1744        let text_only = sections
1745            .iter()
1746            .filter_map(|section| match section {
1747                Section::Metrics { summary, .. } => summary
1748                    .total
1749                    .words_coverage
1750                    .as_ref()?
1751                    .get(&crate::content::CoverageReason::TextOnly)
1752                    .copied(),
1753                _ => None,
1754            })
1755            .max()
1756            .unwrap_or(0);
1757        if text_only > 0 {
1758            let files = if text_only == 1 { "file" } else { "files" };
1759            notes.push(format!(
1760                "note: {text_only} Markdown {files} over {} MiB counted as plain text: every \
1761                 word counted visible, paragraphs are blank-line runs",
1762                crate::content::MARKDOWN_EXACT_BYTES / (1024 * 1024)
1763            ));
1764        }
1765    }
1766    tips.extend(retained_refusals_tip(query, &ignore_rules));
1767    if tree_measurements.is_some_and(|values| values.values().any(|value| !value.complete)) {
1768        notes.push("note: incomplete subtrees remain visible below the size threshold".to_owned());
1769    }
1770    if index.observes_controls() && !index.ignored_classification_complete_below(Path::new("")) {
1771        notes.push(UNVERIFIED_IGNORED_NOTE.to_owned());
1772    }
1773    Ok(Report {
1774        age_reference_ns,
1775        format: query.format,
1776        notes,
1777        tips,
1778        axes: query.axes,
1779        status: TreeStatus::of(index, request),
1780        provenance: ReportProvenance::of(index, content, generated_at),
1781        scope: index.scope(),
1782        requested_analysis: content,
1783        requested_views: query.views.clone(),
1784        omitted_views: query.omitted_views.clone(),
1785        root: index.root_path().to_path_buf(),
1786        size: query.selection.size,
1787        sort_metric: match query.selection.sort {
1788            Some(SortKey::Metric(name)) => Some(name),
1789            _ => None,
1790        },
1791        analysis: index.content().and_then(|held| {
1792            let wanted = index.content_identity(content);
1793            let projected = held.admit(&wanted)?;
1794            Some(ContentReportMetadata {
1795                profile: projected.identity().analysis,
1796                provenance: projected.identity().record_provenance(),
1797            })
1798        }),
1799        ignored_entries: query.selection.ignored,
1800        ignore_rules,
1801        sections,
1802    })
1803}
1804
1805/// Said of a report whose ignored subtotals a refused or unreadable control file withheld.
1806const UNVERIFIED_IGNORED_NOTE: &str =
1807    "note: gitignored subtotals are unavailable where governing rules could not be verified";
1808
1809/// Point at the structured report when the refused-controls note could not name every
1810/// directory it counted.
1811fn retained_refusals_tip(query: &Query, ignore_rules: &ControlCoverage) -> Option<String> {
1812    matches!(ignore_rules, ControlCoverage::Observed(observed) if observed.refusals.len() > REFUSED_DIRECTORIES_NAMED)
1813        .then(|| format!("tip: show retained ignore-file details: {} json", query.axes.format))
1814}
1815
1816/// Build a one-section report from an already reduced exact summary.
1817///
1818/// Pure for the same reason as [`report`]: scanning and time sampling happened before
1819/// this boundary.  The execution planner uses this when a one-shot request proves that
1820/// retaining paths and hierarchy cannot affect its answer. Every field is the one
1821/// [`report`] derives for that request from an index of the same walk: `ignore_rules` is
1822/// the control table's coverage, and `ignored_unverified` says the row withholds its
1823/// ignored share, which [`report`] reads from the index as the root's classification
1824/// being incomplete. The notes are therefore the same notes, in the same order, less
1825/// those about content and trees this tier never answers.
1826#[allow(clippy::too_many_arguments)]
1827pub(crate) fn report_summary(
1828    root: &Path,
1829    scope: ScanScope,
1830    request: &Request,
1831    summary: SummaryRow,
1832    ignore_rules: ControlCoverage,
1833    ignored_unverified: bool,
1834    status: TreeStatus,
1835    provenance: ReportProvenance,
1836) -> Report {
1837    let query = &request.query;
1838    let (mut notes, mut tips) = display_notes(query, request.basis.content, &ignore_rules);
1839    tips.extend(retained_refusals_tip(query, &ignore_rules));
1840    if ignored_unverified {
1841        notes.push(UNVERIFIED_IGNORED_NOTE.to_owned());
1842    }
1843    Report {
1844        age_reference_ns: crate::query::system_time_to_nanos(request.now),
1845        format: query.format,
1846        notes,
1847        tips,
1848        axes: query.axes,
1849        status,
1850        provenance,
1851        scope,
1852        requested_analysis: AnalysisSet::NONE,
1853        requested_views: query.views.clone(),
1854        omitted_views: query.omitted_views.clone(),
1855        root: root.to_path_buf(),
1856        size: query.selection.size,
1857        sort_metric: match query.selection.sort {
1858            Some(SortKey::Metric(name)) => Some(name),
1859            _ => None,
1860        },
1861        // The planner only selects this tier when no analysis was requested, so there is
1862        // no analyzer provenance to report.
1863        analysis: None,
1864        ignored_entries: query.selection.ignored,
1865        ignore_rules,
1866        sections: vec![Section::Summary(summary)],
1867    }
1868}
1869
1870/// Aggregates gathered by one filtered traversal.
1871struct Walked {
1872    /// Whether the walked index observed control state, so its rows carry ignored shares.
1873    observed: bool,
1874    /// Filtered subtree aggregates, keyed by directory id.
1875    ///
1876    /// A row's `ignored` stays `None` until an ignored entry is admitted beneath it;
1877    /// [`Self::summary_of`] is what reads it as the index's observation says.
1878    per_directory: BTreeMap<EntryId, SummaryRow>,
1879    /// Filtered per-extension tallies.
1880    by_ext: BTreeMap<String, ExtTally>,
1881    /// The ignored part of each filtered per-extension tally, for extensions that have one.
1882    ignored_by_ext: BTreeMap<String, ExtTally>,
1883    /// Entries the selection admitted.
1884    rows: Vec<FileRow>,
1885    /// Regular files in the union of matches and selected subtrees, counted once.
1886    members: Vec<FileRow>,
1887    /// Directories in that union or on a path to it, including empty matches.
1888    visible: BTreeSet<EntryId>,
1889    visible_files: BTreeSet<EntryId>,
1890    /// Selected contents whose ignore classification is unavailable, by ancestor directory.
1891    unknown_ignored: BTreeSet<EntryId>,
1892}
1893
1894impl Walked {
1895    /// One directory's filtered totals, with an ignored share exactly when observed.
1896    fn summary_of(&self, id: EntryId) -> SummaryRow {
1897        let mut row = self.per_directory.get(&id).copied().unwrap_or_default();
1898        row.ignored = (self.observed && !self.unknown_ignored.contains(&id))
1899            .then(|| row.ignored.unwrap_or_default());
1900        row
1901    }
1902}
1903
1904/// One directory's unfiltered totals from the roll-up state the index maintains, with its
1905/// ignored share, `all` less `unignored`, when the index observed control state.
1906fn unfiltered_summary(index: &Index, id: EntryId, path: &Path) -> SummaryRow {
1907    let observed = index.observes_controls() && index.ignored_classification_complete_below(path);
1908    let Some((all, unignored)) = index.partition_scalars_of(id) else {
1909        return SummaryRow {
1910            ignored: observed.then(IgnoredTally::default),
1911            ..SummaryRow::default()
1912        };
1913    };
1914    SummaryRow {
1915        ignored: observed.then(|| IgnoredTally::between(all, unignored)),
1916        ..summary_from_scalars(all)
1917    }
1918}
1919
1920/// Walk the retained index once, aggregating only what the selection admits.
1921///
1922/// Iterative rather than recursive: this engine is built for trees deep enough that a
1923/// recursive post-order would exhaust the stack.
1924fn walk(
1925    index: &Index,
1926    selection: &Selection,
1927    identity: NameIdentity,
1928    directories: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
1929) -> Walked {
1930    debug_assert!(!index.is_folded(), "a folded index keeps too few files to be filtered");
1931    let observed = index.observes_controls();
1932    let mut walked = Walked {
1933        observed,
1934        per_directory: BTreeMap::new(),
1935        by_ext: BTreeMap::new(),
1936        ignored_by_ext: BTreeMap::new(),
1937        rows: Vec::new(),
1938        members: Vec::new(),
1939        visible: BTreeSet::new(),
1940        visible_files: BTreeSet::new(),
1941        unknown_ignored: BTreeSet::new(),
1942    };
1943    // No entry of an index that read no rule can be shown to be ignored or not, so
1944    // `report_in` refuses a selection by ignored state before it reaches this walk.
1945    debug_assert!(
1946        observed || selection.ignored == IgnoredEntries::Include,
1947        "a selection by ignored state over an unobserving index is refused before the walk"
1948    );
1949
1950    // (id, path, post-order, covered by a selected ancestor)
1951    let mut stack = vec![(EntryId::ROOT, PathBuf::new(), false, false)];
1952    while let Some((id, path, expanded, covered)) = stack.pop() {
1953        if expanded {
1954            // Post-order: every child has finished, so fold their totals into this one.
1955            // `total` already carries this directory's own admitted files and admitted
1956            // directory children, both tallied in the pre-order pass below; what is left
1957            // is to add what each child subtree found deeper down.
1958            let mut total = walked.per_directory.remove(&id).unwrap_or_default();
1959            if let Some(children) = index.children_of(id) {
1960                for (_, child) in children {
1961                    if let Some(sub) = walked.per_directory.get(&child) {
1962                        let sub = *sub;
1963                        merge_summary(&mut total, &sub);
1964                        if walked.unknown_ignored.contains(&child) {
1965                            walked.unknown_ignored.insert(id);
1966                        }
1967                    }
1968                }
1969            }
1970            if total.files > 0 || total.dirs > 0 {
1971                walked.visible.insert(id);
1972            }
1973            walked.per_directory.insert(id, total);
1974            continue;
1975        }
1976
1977        stack.push((id, path.clone(), true, covered));
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
1984        for (child_path, child) in children {
1985            let (Some(kind), Some(attrs)) = (index.kind_of(child), index.attrs_of(child)) else {
1986                continue;
1987            };
1988            // Bound once, and as an `OsStr`: the bucket has to be derived from the same
1989            // bytes the index interned from, or a name that is not valid UTF-8 would be
1990            // filed under one label by the fast tier and another by this one.
1991            let file_name = child_path.file_name().unwrap_or_default();
1992            let classification = index.ignored_classification_of(&child_path, child);
1993            let ignored = classification.unwrap_or(false);
1994            let classification_admitted =
1995                classification.is_some() || selection.ignored == IgnoredEntries::Include;
1996            let mut measured = *attrs;
1997            let subtree = directories.and_then(|values| values.get(&child)).copied();
1998            if let Some(subtree) = subtree {
1999                measured.size = subtree.bytes;
2000                measured.allocated = subtree.allocated;
2001                measured.mtime_ns = subtree.mtime_ns;
2002            }
2003            let (pruned, matches) = query_subtrees::with_candidate(
2004                &child_path,
2005                kind,
2006                measured,
2007                ignored,
2008                identity,
2009                |candidate| {
2010                    (query_subtrees::pruned(selection, &candidate), selection.admits(&candidate))
2011                },
2012            );
2013            if pruned {
2014                continue;
2015            }
2016            // An incomplete subtree's newest activity is a lower bound, not an age, so no
2017            // modification bound can be shown to hold for it: `before` could be disproved
2018            // by any unlisted descendant, and `since`, which a lower bound could prove, is
2019            // held to the same rule so that a row's presence under a time filter always
2020            // means the filter was decided on a complete measurement. A size bound still
2021            // matches, since a lower bound at or above the minimum proves the true size is.
2022            let matches = matches
2023                && classification_admitted
2024                && (selection.modified.is_unbounded()
2025                    || subtree.is_none_or(|subtree| subtree.complete));
2026            let row = FileRow {
2027                path: child_path.clone(),
2028                kind,
2029                bytes: measured.size,
2030                allocated: measured.allocated,
2031                mtime_ns: measured.mtime_ns,
2032                files: subtree.map(|subtree| subtree.files),
2033                dirs: subtree.map(|subtree| subtree.dirs),
2034                complete: subtree.map(|subtree| subtree.complete),
2035                age_ns: None,
2036                ignored: classification,
2037                sort_value: None,
2038                classification: None,
2039            };
2040            if matches {
2041                walked.rows.push(row.clone());
2042            }
2043            if matches || (covered && classification_admitted && selection.ignored.admits(ignored))
2044            {
2045                if classification.is_none() {
2046                    walked.unknown_ignored.insert(id);
2047                }
2048                if kind == EntryKind::File {
2049                    walked.members.push(row);
2050                    walked.visible_files.insert(child);
2051                } else if kind == EntryKind::Dir {
2052                    walked.visible.insert(child);
2053                }
2054
2055                if kind == EntryKind::File {
2056                    let own = walked.per_directory.entry(id).or_default();
2057                    own.files += 1;
2058                    own.bytes += attrs.size;
2059                    own.allocated += attrs.allocated;
2060                    own.newest_mtime_ns = Some(
2061                        own.newest_mtime_ns.map_or(attrs.mtime_ns, |seen| seen.max(attrs.mtime_ns)),
2062                    );
2063                    let bucket = crate::classify::ext_bucket(file_name);
2064                    if ignored {
2065                        own.ignored.get_or_insert_with(IgnoredTally::default).add(IgnoredTally {
2066                            files: 1,
2067                            dirs: 0,
2068                            bytes: attrs.size,
2069                            allocated: attrs.allocated,
2070                        });
2071                        let tally = walked.ignored_by_ext.entry(bucket.clone()).or_default();
2072                        tally.files += 1;
2073                        tally.bytes += attrs.size;
2074                        tally.allocated += attrs.allocated;
2075                    }
2076                    let tally = walked.by_ext.entry(bucket).or_default();
2077                    tally.files += 1;
2078                    tally.bytes += attrs.size;
2079                    tally.allocated += attrs.allocated;
2080                } else if kind == EntryKind::Dir {
2081                    // Tallied here, beside the file case, rather than in the post-order
2082                    // fold: the fold sees every directory the walk descended into, and
2083                    // counting there reported directories the selection had rejected.
2084                    // `--kind file` answered "6 files, 3 directories", and a summary
2085                    // disagreed with the files view over the very same query.
2086                    let own = walked.per_directory.entry(id).or_default();
2087                    own.dirs += 1;
2088                    if ignored {
2089                        own.ignored.get_or_insert_with(IgnoredTally::default).dirs += 1;
2090                    }
2091                }
2092            }
2093
2094            if kind == EntryKind::Dir {
2095                stack.push((child, child_path, false, covered || matches));
2096            }
2097        }
2098    }
2099
2100    walked
2101}
2102
2103/// Fold one subtree's filtered totals into another's.
2104fn merge_summary(into: &mut SummaryRow, from: &SummaryRow) {
2105    into.files += from.files;
2106    into.dirs += from.dirs;
2107    into.bytes += from.bytes;
2108    into.allocated += from.allocated;
2109    into.newest_mtime_ns = match (into.newest_mtime_ns, from.newest_mtime_ns) {
2110        (Some(left), Some(right)) => Some(left.max(right)),
2111        (left, right) => left.or(right),
2112    };
2113    if let Some(share) = from.ignored {
2114        into.ignored.get_or_insert_with(IgnoredTally::default).add(share);
2115    }
2116}
2117
2118/// Views that reconstruct every path into a [`FileRow`] when the selection is unfiltered.
2119fn needs_unfiltered_entry_rows(view: ViewSpec) -> bool {
2120    matches!(
2121        view,
2122        ViewSpec::Types
2123            | ViewSpec::Families
2124            | ViewSpec::Languages
2125            | ViewSpec::Code
2126            | ViewSpec::Documents
2127            | ViewSpec::Files
2128            | ViewSpec::Largest
2129            | ViewSpec::Recent
2130    )
2131}
2132
2133/// Content-grouping views that resolve the same current classification and analysis row.
2134fn needs_metric_resolution(view: ViewSpec) -> bool {
2135    matches!(view, ViewSpec::Types | ViewSpec::Families | ViewSpec::Languages | ViewSpec::Documents)
2136}
2137
2138/// The entry rows a view aggregates: the filtered walk, a shared unfiltered walk, or a
2139/// fresh [`every_entry`] when this is the only consumer.
2140fn entry_rows<'a>(
2141    index: &Index,
2142    walked: Option<&'a Walked>,
2143    unfiltered_rows: Option<&'a [FileRow]>,
2144) -> Cow<'a, [FileRow]> {
2145    match (walked, unfiltered_rows) {
2146        (Some(walked), _) => Cow::Borrowed(&walked.rows),
2147        (None, Some(rows)) => Cow::Borrowed(rows),
2148        (None, None) => Cow::Owned(every_entry(index)),
2149    }
2150}
2151
2152/// Build one view's section, using the pre-computed tier when the selection allows.
2153fn build_section(
2154    view: ViewSpec,
2155    index: &Index,
2156    query: &Query,
2157    content: AnalysisSet,
2158    walked: Option<&Walked>,
2159    unfiltered_rows: Option<&[FileRow]>,
2160    tree_measurements: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
2161) -> Section {
2162    if query.tree_for(view) {
2163        let (root, omissions) = tree_node(index, query, content, walked, tree_measurements);
2164        let limits = TreeDisplayLimits {
2165            depth: query.depth_for(view),
2166            min_share: query.min_share_for(),
2167            breadth: query.breadth_for(),
2168            rows: query.limit_for(view),
2169        };
2170        return Section::Tree { view, limits, root: root.map(Box::new), omissions };
2171    }
2172    match view {
2173        ViewSpec::Code => {
2174            Section::Code(Box::new(code_overview(index, query, content, walked, unfiltered_rows)))
2175        }
2176        ViewSpec::Summary => Section::Summary(match walked {
2177            None => unfiltered_summary(index, EntryId::ROOT, Path::new("")),
2178            Some(walked) => walked.summary_of(EntryId::ROOT),
2179        }),
2180        ViewSpec::Extensions => {
2181            let (rows, total, share_omitted) = extension_rows(index, query, walked);
2182            Section::Extensions { rows, total, share_omitted }
2183        }
2184        ViewSpec::Types | ViewSpec::Families | ViewSpec::Languages | ViewSpec::Documents => {
2185            Section::Metrics {
2186                view,
2187                summary: Box::new(metric_summary(
2188                    view,
2189                    index,
2190                    query,
2191                    content,
2192                    walked,
2193                    unfiltered_rows,
2194                )),
2195            }
2196        }
2197        ViewSpec::List
2198        | ViewSpec::Tree
2199        | ViewSpec::Files
2200        | ViewSpec::Largest
2201        | ViewSpec::Recent => {
2202            let (rows, total) = file_rows(view, index, query, content, walked, unfiltered_rows);
2203            Section::Files { view, rows, total }
2204        }
2205    }
2206}
2207
2208/// A summary row taken straight from pre-computed roll-up state, before any ignored share.
2209fn summary_from_scalars(rollup: RollUpScalars) -> SummaryRow {
2210    SummaryRow {
2211        files: rollup.files,
2212        dirs: rollup.dirs,
2213        bytes: rollup.bytes,
2214        allocated: rollup.allocated,
2215        ignored: None,
2216        newest_mtime_ns: (rollup.files > 0).then_some(rollup.newest_mtime_ns),
2217    }
2218}
2219
2220/// What each extension tally in `all` holds beyond the same extension in `unignored`.
2221fn ignored_by_extension(
2222    all: &BTreeMap<String, ExtTally>,
2223    unignored: &BTreeMap<String, ExtTally>,
2224) -> BTreeMap<String, ExtTally> {
2225    all.iter()
2226        .filter_map(|(extension, tally)| {
2227            let kept = unignored.get(extension).copied().unwrap_or_default();
2228            let ignored = ExtTally {
2229                files: tally.files.saturating_sub(kept.files),
2230                bytes: tally.bytes.saturating_sub(kept.bytes),
2231                allocated: tally.allocated.saturating_sub(kept.allocated),
2232            };
2233            (ignored.files > 0).then(|| (extension.clone(), ignored))
2234        })
2235        .collect()
2236}
2237
2238/// Rows for the types view.
2239fn extension_rows(
2240    index: &Index,
2241    query: &Query,
2242    walked: Option<&Walked>,
2243) -> (Vec<TypeRow>, usize, usize) {
2244    debug_assert!(!index.is_folded(), "a folded index keeps no extension tallies (H176)");
2245    // Extension partitions cannot attribute an unknown member to one bucket from the
2246    // roll-up alone, so withhold their ignored subtotals until the scope is known.
2247    let observed = match walked {
2248        Some(walked) => walked.observed && !walked.unknown_ignored.contains(&EntryId::ROOT),
2249        None => {
2250            index.observes_controls() && index.ignored_classification_complete_below(Path::new(""))
2251        }
2252    };
2253    let (tallies, ignored): (BTreeMap<String, ExtTally>, BTreeMap<String, ExtTally>) = match walked
2254    {
2255        None => match index.partition_total() {
2256            Ok(partitions) => {
2257                let ignored =
2258                    ignored_by_extension(&partitions.all.by_ext, &partitions.unignored.by_ext);
2259                (partitions.all.by_ext, ignored)
2260            }
2261            // An index that observed no control state has no unignored partition to
2262            // subtract, and its rows carry no ignored share.
2263            Err(_not_observed) => (index.total().by_ext, BTreeMap::new()),
2264        },
2265        Some(walked) => (walked.by_ext.clone(), walked.ignored_by_ext.clone()),
2266    };
2267
2268    let mut rows: Vec<TypeRow> = tallies
2269        .into_iter()
2270        .map(|(extension, tally)| {
2271            let share = ignored.get(&extension).copied().unwrap_or_default();
2272            TypeRow {
2273                files: tally.files,
2274                bytes: tally.bytes,
2275                allocated: tally.allocated,
2276                ignored: observed.then_some(IgnoredTally {
2277                    files: share.files,
2278                    dirs: 0,
2279                    bytes: share.bytes,
2280                    allocated: share.allocated,
2281                }),
2282                extension,
2283            }
2284        })
2285        .collect();
2286
2287    let before_share = rows.len();
2288    if let Some(threshold) = &query.selection.min_share {
2289        let root = match walked {
2290            None => unfiltered_summary(index, EntryId::ROOT, Path::new("")),
2291            Some(walked) => walked.summary_of(EntryId::ROOT),
2292        };
2293        let denominator = match query.selection.size {
2294            SizeMetric::Apparent => root.bytes,
2295            SizeMetric::Allocated => root.allocated,
2296        };
2297        rows.retain(|row| {
2298            threshold.admits(
2299                match query.selection.size {
2300                    SizeMetric::Apparent => row.bytes,
2301                    SizeMetric::Allocated => row.allocated,
2302                },
2303                denominator,
2304            )
2305        });
2306    }
2307
2308    sort_rows(
2309        &mut rows,
2310        query,
2311        ViewSpec::Extensions,
2312        SortAccessors {
2313            size: |row: &TypeRow, metric| match metric {
2314                SizeMetric::Apparent => row.bytes,
2315                SizeMetric::Allocated => row.allocated,
2316            },
2317            count: |row: &TypeRow| row.files,
2318            mtime: |_: &TypeRow| None,
2319            name: borrowed_name(|row: &TypeRow| std::borrow::Cow::Borrowed(row.extension.as_str())),
2320            content_metric: |_: &TypeRow, _: &MetricDef| None,
2321        },
2322    );
2323    let total = truncate(&mut rows, query.limit_for(ViewSpec::Extensions));
2324    (rows, total, before_share - total)
2325}
2326
2327fn metric_summary(
2328    view: ViewSpec,
2329    index: &Index,
2330    query: &Query,
2331    content: AnalysisSet,
2332    walked: Option<&Walked>,
2333    unfiltered_rows: Option<&[FileRow]>,
2334) -> MetricSummary {
2335    let mut accumulator = MetricAccumulator::new(view);
2336    let files = walked.map_or_else(
2337        || entry_rows(index, None, unfiltered_rows),
2338        |walked| Cow::Borrowed(walked.members.as_slice()),
2339    );
2340    let wanted = index.content_identity(content);
2341    let held = index.content().and_then(|held| held.admit(&wanted));
2342    for file in files.iter().filter(|row| row.kind == EntryKind::File) {
2343        let cached = held.and_then(|content| content.file(&file.path));
2344        let classification = index.classify(&file.path);
2345        accumulator.push(content, file, cached, &classification);
2346    }
2347    accumulator.finish(query, content)
2348}
2349
2350/// Build several unfiltered metric sections in one file pass.
2351fn metric_summaries(
2352    views: &[ViewSpec],
2353    index: &Index,
2354    query: &Query,
2355    content: AnalysisSet,
2356    rows: &[FileRow],
2357) -> Vec<Option<MetricSummary>> {
2358    let mut accumulators = views
2359        .iter()
2360        .copied()
2361        .filter(|view| needs_metric_resolution(*view))
2362        .map(MetricAccumulator::new)
2363        .collect::<Vec<_>>();
2364    let wanted = index.content_identity(content);
2365    let held = index.content().and_then(|held| held.admit(&wanted));
2366    for file in rows.iter().filter(|row| row.kind == EntryKind::File) {
2367        let cached = held.and_then(|content| content.file(&file.path));
2368        let classification = index.classify(&file.path);
2369        for accumulator in &mut accumulators {
2370            accumulator.push(content, file, cached, &classification);
2371        }
2372    }
2373
2374    let mut finished =
2375        accumulators.into_iter().map(|accumulator| accumulator.finish(query, content));
2376    let summaries = views
2377        .iter()
2378        .map(|view| needs_metric_resolution(*view).then(|| finished.next().expect("metric view")))
2379        .collect();
2380    debug_assert!(finished.next().is_none());
2381    summaries
2382}
2383
2384struct MetricAccumulator {
2385    view: ViewSpec,
2386    group: MetricGroup,
2387    grouped: BTreeMap<String, MetricRow>,
2388}
2389
2390impl MetricAccumulator {
2391    fn new(view: ViewSpec) -> Self {
2392        debug_assert!(needs_metric_resolution(view));
2393        let group =
2394            if view == ViewSpec::Families { MetricGroup::Family } else { MetricGroup::Type };
2395        Self { view, group, grouped: BTreeMap::new() }
2396    }
2397
2398    fn push(
2399        &mut self,
2400        content: AnalysisSet,
2401        file: &FileRow,
2402        cached: Option<&FileAnalysis>,
2403        classification: &Classification,
2404    ) {
2405        let included = match self.view {
2406            ViewSpec::Languages => classification.family == ContentFamily::Code,
2407            ViewSpec::Documents => {
2408                matches!(classification.family, ContentFamily::Prose | ContentFamily::Markup)
2409            }
2410            ViewSpec::Types | ViewSpec::Families => true,
2411            ViewSpec::List
2412            | ViewSpec::Tree
2413            | ViewSpec::Extensions
2414            | ViewSpec::Code
2415            | ViewSpec::Files
2416            | ViewSpec::Largest
2417            | ViewSpec::Recent
2418            | ViewSpec::Summary => false,
2419        };
2420        if !included {
2421            return;
2422        }
2423        let id = match self.group {
2424            MetricGroup::Type => classification.file_type.as_str().to_string(),
2425            MetricGroup::Family => classification.family.as_str().to_string(),
2426        };
2427        let row = self.grouped.entry(id.clone()).or_insert_with(|| MetricRow {
2428            analysis: content,
2429            id,
2430            family: classification.family,
2431            files: 0,
2432            bytes: 0,
2433            allocated: 0,
2434            analyzed_files: 0,
2435            metrics: ReportMetricValues::for_analysis(content),
2436            logical_word_stats: LogicalWordStats::default(),
2437            visible_logical_word_stats: LogicalWordStats::default(),
2438            document_raw_words: 0,
2439            document_word_stats: LogicalWordStats::default(),
2440            document_metric_files: 0,
2441            coverage: BTreeMap::new(),
2442            lines_coverage: BTreeMap::new(),
2443            code_coverage: content.includes_code().then(BTreeMap::new),
2444            words_coverage: content.includes_words().then(BTreeMap::new),
2445            detection_sources: BTreeMap::new(),
2446            detection_confidence: BTreeMap::new(),
2447            generated_files: 0,
2448            vendored_files: 0,
2449            documentation_files: 0,
2450            share: MetricShare::default(),
2451        });
2452        row.files = row.files.saturating_add(1);
2453        row.bytes = row.bytes.saturating_add(file.bytes);
2454        row.allocated = row.allocated.saturating_add(file.allocated);
2455        let detection = cached.map_or(
2456            (classification.source, classification.confidence, classification.flags),
2457            |record| (record.detection.source, record.detection.confidence, record.detection.flags),
2458        );
2459        *row.detection_sources.entry(detection.0).or_default() += 1;
2460        *row.detection_confidence.entry(detection.1).or_default() += 1;
2461        row.generated_files = row.generated_files.saturating_add(u64::from(detection.2.generated));
2462        row.vendored_files = row.vendored_files.saturating_add(u64::from(detection.2.vendored));
2463        row.documentation_files =
2464            row.documentation_files.saturating_add(u64::from(detection.2.documentation));
2465        if let Some(record) = cached {
2466            *row.lines_coverage.entry(record.lines.coverage()).or_default() += 1;
2467            if let (Some(coverage), Some(outcome)) = (&mut row.code_coverage, record.code) {
2468                *coverage.entry(outcome.coverage()).or_default() += 1;
2469            }
2470            if let (Some(coverage), Some(outcome)) = (&mut row.words_coverage, record.words) {
2471                *coverage.entry(outcome.coverage()).or_default() += 1;
2472            }
2473            let selected = match self.view {
2474                ViewSpec::Languages if content.includes_code() => {
2475                    record.code.map(|outcome| outcome.coverage())
2476                }
2477                ViewSpec::Documents if content.includes_words() => {
2478                    record.words.map(|outcome| outcome.coverage())
2479                }
2480                _ => None,
2481            }
2482            .unwrap_or(record.lines.coverage());
2483            *row.coverage.entry(selected).or_default() += 1;
2484            if selected == CoverageReason::Analyzed {
2485                row.analyzed_files = row.analyzed_files.saturating_add(1);
2486            }
2487            if let Some(lines) = record.lines.value() {
2488                add_optional(&mut row.metrics.physical_lines, Some(lines.physical_lines));
2489                add_optional(&mut row.metrics.blank_lines, Some(lines.blank_lines));
2490                add_optional(&mut row.metrics.nonblank_lines, Some(lines.nonblank_lines));
2491                add_optional(&mut row.metrics.raw_words, Some(lines.raw_words));
2492                row.document_raw_words = row.document_raw_words.saturating_add(lines.raw_words);
2493            }
2494            if let Some(code_metrics) = record.code.and_then(crate::content::AnalyzerOutcome::value)
2495            {
2496                add_optional(&mut row.metrics.code_lines, Some(code_metrics.code_lines));
2497                add_optional(&mut row.metrics.comment_lines, Some(code_metrics.comment_lines));
2498                add_optional(
2499                    &mut row.metrics.code_blank_lines,
2500                    Some(code_metrics.code_blank_lines),
2501                );
2502            }
2503            if let Some(words) = record.words.and_then(crate::content::AnalyzerOutcome::value) {
2504                add_optional(&mut row.metrics.paragraphs, Some(words.paragraphs));
2505                add_optional(&mut row.metrics.visible_words, Some(words.visible_words));
2506                row.logical_word_stats.add_assign(words.logical_word_stats);
2507                row.visible_logical_word_stats.add_assign(words.visible_logical_word_stats);
2508                row.document_metric_files = row.document_metric_files.saturating_add(1);
2509                if classification.file_type.as_str() == "markdown" {
2510                    row.document_raw_words = row
2511                        .document_raw_words
2512                        .saturating_sub(record.lines.value().map_or(0, |lines| lines.raw_words));
2513                    row.document_raw_words =
2514                        row.document_raw_words.saturating_add(words.visible_words);
2515                    row.document_word_stats.add_assign(words.visible_logical_word_stats);
2516                } else {
2517                    row.document_word_stats.add_assign(words.logical_word_stats);
2518                }
2519            }
2520        }
2521    }
2522
2523    fn finish(self, query: &Query, content: AnalysisSet) -> MetricSummary {
2524        let Self { view, group, mut grouped } = self;
2525
2526        for row in grouped.values_mut() {
2527            row.finish_derived_metrics();
2528        }
2529
2530        let mut total = MetricRow {
2531            analysis: content,
2532            id: "total".to_string(),
2533            family: ContentFamily::Unknown,
2534            files: 0,
2535            bytes: 0,
2536            allocated: 0,
2537            analyzed_files: 0,
2538            metrics: ReportMetricValues::for_analysis(content),
2539            logical_word_stats: LogicalWordStats::default(),
2540            visible_logical_word_stats: LogicalWordStats::default(),
2541            document_raw_words: 0,
2542            document_word_stats: LogicalWordStats::default(),
2543            document_metric_files: 0,
2544            coverage: BTreeMap::new(),
2545            lines_coverage: BTreeMap::new(),
2546            code_coverage: content.includes_code().then(BTreeMap::new),
2547            words_coverage: content.includes_words().then(BTreeMap::new),
2548            detection_sources: BTreeMap::new(),
2549            detection_confidence: BTreeMap::new(),
2550            generated_files: 0,
2551            vendored_files: 0,
2552            documentation_files: 0,
2553            share: MetricShare::default(),
2554        };
2555        for row in grouped.values() {
2556            total.files = total.files.saturating_add(row.files);
2557            total.bytes = total.bytes.saturating_add(row.bytes);
2558            total.allocated = total.allocated.saturating_add(row.allocated);
2559            total.analyzed_files = total.analyzed_files.saturating_add(row.analyzed_files);
2560            total.metrics.add_assign(&row.metrics);
2561            total.logical_word_stats.add_assign(row.logical_word_stats);
2562            total.visible_logical_word_stats.add_assign(row.visible_logical_word_stats);
2563            total.document_raw_words =
2564                total.document_raw_words.saturating_add(row.document_raw_words);
2565            total.document_word_stats.add_assign(row.document_word_stats);
2566            total.document_metric_files =
2567                total.document_metric_files.saturating_add(row.document_metric_files);
2568            for (reason, count) in &row.coverage {
2569                *total.coverage.entry(*reason).or_default() += count;
2570            }
2571            merge_coverage(&mut total.lines_coverage, &row.lines_coverage);
2572            if let (Some(total), Some(row)) = (&mut total.code_coverage, &row.code_coverage) {
2573                merge_coverage(total, row);
2574            }
2575            if let (Some(total), Some(row)) = (&mut total.words_coverage, &row.words_coverage) {
2576                merge_coverage(total, row);
2577            }
2578            for (source, count) in &row.detection_sources {
2579                *total.detection_sources.entry(*source).or_default() += count;
2580            }
2581            for (confidence, count) in &row.detection_confidence {
2582                *total.detection_confidence.entry(*confidence).or_default() += count;
2583            }
2584            total.generated_files = total.generated_files.saturating_add(row.generated_files);
2585            total.vendored_files = total.vendored_files.saturating_add(row.vendored_files);
2586            total.documentation_files =
2587                total.documentation_files.saturating_add(row.documentation_files);
2588        }
2589        total.finish_derived_metrics();
2590        let byte_share_metric = match query.selection.size {
2591            SizeMetric::Apparent => ShareMetric::ApparentBytes,
2592            SizeMetric::Allocated => ShareMetric::AllocatedBytes,
2593        };
2594        let share_metric = match view {
2595            // The requested analyzers, not the stored ones: a share is a fact about what the
2596            // request asked to measure, and `validate_read` proved the index holds exactly it.
2597            ViewSpec::Languages if content.includes_code() => ShareMetric::CodeLines,
2598            ViewSpec::Documents if content.includes_words() => ShareMetric::DocumentWords,
2599            ViewSpec::Languages | ViewSpec::Documents if content.is_enabled() => {
2600                ShareMetric::RawWords
2601            }
2602            ViewSpec::Languages | ViewSpec::Documents | ViewSpec::Types | ViewSpec::Families => {
2603                byte_share_metric
2604            }
2605            ViewSpec::List
2606            | ViewSpec::Tree
2607            | ViewSpec::Extensions
2608            | ViewSpec::Code
2609            | ViewSpec::Files
2610            | ViewSpec::Largest
2611            | ViewSpec::Recent
2612            | ViewSpec::Summary => {
2613                unreachable!("only grouped views reach metric_summary")
2614            }
2615        };
2616        let mut rows = grouped.into_values().collect::<Vec<_>>();
2617        let denominator = share_denominator(&rows, share_metric);
2618        total.share = MetricShare { numerator: denominator, denominator };
2619        for row in &mut rows {
2620            row.share = MetricShare { numerator: share_value(row, share_metric), denominator };
2621        }
2622        sort_rows(
2623            &mut rows,
2624            query,
2625            view,
2626            SortAccessors {
2627                size: |row: &MetricRow, metric| match view {
2628                    ViewSpec::Languages | ViewSpec::Documents => share_value(row, share_metric),
2629                    _ => match metric {
2630                        SizeMetric::Apparent => row.bytes,
2631                        SizeMetric::Allocated => row.allocated,
2632                    },
2633                },
2634                count: |row: &MetricRow| row.files,
2635                mtime: |_: &MetricRow| None,
2636                name: borrowed_name(|row: &MetricRow| std::borrow::Cow::Borrowed(row.id.as_str())),
2637                content_metric: MetricRow::metric_value,
2638            },
2639        );
2640        let before_share = rows.len();
2641        if let Some(threshold) = &query.selection.min_share {
2642            rows.retain(|row| threshold.admits(row.share.numerator, row.share.denominator));
2643        }
2644        let share_omitted = before_share - rows.len();
2645        let total_rows = truncate(&mut rows, query.limit_for(view));
2646        MetricSummary {
2647            group,
2648            total,
2649            rows,
2650            total_rows,
2651            share_omitted,
2652            share_metric,
2653            words_per_page: query.words_per_page.max(1),
2654        }
2655    }
2656}
2657
2658fn code_overview(
2659    index: &Index,
2660    query: &Query,
2661    content: AnalysisSet,
2662    walked: Option<&Walked>,
2663    unfiltered_rows: Option<&[FileRow]>,
2664) -> CodeOverview {
2665    #[derive(Default)]
2666    struct SortFacts {
2667        apparent: u64,
2668        allocated: u64,
2669        newest_mtime_ns: Option<i64>,
2670        metric: MetricAggregate,
2671    }
2672
2673    let files = walked.map_or_else(
2674        || entry_rows(index, None, unfiltered_rows),
2675        |walked| Cow::Borrowed(walked.members.as_slice()),
2676    );
2677    let held = index.content().and_then(|tier| tier.admit(&index.content_identity(content)));
2678    let split = query.selection.ignored == IgnoredEntries::Include && index.observes_controls();
2679    let sort_key = query.selection.sort.unwrap_or(SortKey::Metric("code_lines"));
2680    let sort_metric = match sort_key {
2681        SortKey::Metric(name) => Some(name),
2682        _ => None,
2683    };
2684    let mut overview = CodeOverview {
2685        population: query.selection.ignored,
2686        selected: CodeTally::default(),
2687        non_ignored: split.then(CodeTally::default),
2688        ignored: split.then(CodeTally::default),
2689        unknown: CodeTally::default(),
2690        unclassified_files: 0,
2691        analyzed_languages: 0,
2692        total_languages: 0,
2693        share_omitted: 0,
2694        languages: Vec::new(),
2695        share_metric: ShareMetric::CodeLines,
2696    };
2697    let mut grouped = BTreeMap::<String, CodeLanguageRow>::new();
2698    let mut sort_facts = BTreeMap::<String, SortFacts>::new();
2699    for file in files.iter().filter(|row| row.kind == EntryKind::File) {
2700        let record = held.and_then(|tier| tier.file(&file.path));
2701        let classification = record
2702            .map_or_else(|| index.classify(&file.path).into(), |record| record.detection.clone());
2703        if classification.family == ContentFamily::Unknown {
2704            overview.unclassified_files = overview.unclassified_files.saturating_add(1);
2705        }
2706        if classification.family != ContentFamily::Code {
2707            continue;
2708        }
2709        let language = classification.file_type.as_str().to_string();
2710        let facts = sort_facts.entry(language.clone()).or_default();
2711        facts.apparent = facts.apparent.saturating_add(file.bytes);
2712        facts.allocated = facts.allocated.saturating_add(file.allocated);
2713        facts.newest_mtime_ns =
2714            Some(facts.newest_mtime_ns.map_or(file.mtime_ns, |old| old.max(file.mtime_ns)));
2715        if let (Some(name), Some(record)) = (sort_metric, record) {
2716            if let Some(value) =
2717                measured_file_metric(record, classification.file_type.as_str(), name)
2718            {
2719                facts.metric.add(value);
2720            }
2721        }
2722        let row = grouped.entry(language.clone()).or_insert_with(|| CodeLanguageRow {
2723            language,
2724            selected: CodeTally::default(),
2725            non_ignored: split.then(CodeTally::default),
2726            ignored: split.then(CodeTally::default),
2727            unknown: CodeTally::default(),
2728            share: MetricShare::default(),
2729        });
2730        row.selected.add_file(record);
2731        overview.selected.add_file(record);
2732        match index.ignored_classification(&file.path) {
2733            Some(false) if split => {
2734                row.non_ignored.as_mut().expect("split initialized").add_file(record);
2735                overview.non_ignored.as_mut().expect("split initialized").add_file(record);
2736            }
2737            Some(true) if split => {
2738                row.ignored.as_mut().expect("split initialized").add_file(record);
2739                overview.ignored.as_mut().expect("split initialized").add_file(record);
2740            }
2741            None => {
2742                row.unknown.add_file(record);
2743                overview.unknown.add_file(record);
2744            }
2745            Some(_) => {}
2746        }
2747    }
2748    let denominator = overview.selected.metrics.code_lines;
2749    overview.analyzed_languages =
2750        grouped.values().filter(|row| row.selected.analyzed_files > 0).count() as u64;
2751    overview.languages = grouped.into_values().collect();
2752    for row in &mut overview.languages {
2753        row.share = MetricShare { numerator: row.selected.metrics.code_lines, denominator };
2754    }
2755    let normalized =
2756        matches!(sort_metric, Some("logical_words" | "visible_logical_words" | "document_words"));
2757    overview.languages.sort_by(|left, right| {
2758        let left_facts = &sort_facts[&left.language];
2759        let right_facts = &sort_facts[&right.language];
2760        let ordering = match sort_key {
2761            SortKey::Size => {
2762                let value = |facts: &SortFacts| match query.selection.size {
2763                    SizeMetric::Apparent => facts.apparent,
2764                    SizeMetric::Allocated => facts.allocated,
2765                };
2766                value(right_facts).cmp(&value(left_facts))
2767            }
2768            SortKey::Count => right.selected.source_files.cmp(&left.selected.source_files),
2769            SortKey::Mtime => right_facts.newest_mtime_ns.cmp(&left_facts.newest_mtime_ns),
2770            SortKey::Name => left.language.cmp(&right.language),
2771            SortKey::Metric(_) => {
2772                let left_value = left_facts.metric.value(normalized);
2773                let right_value = right_facts.metric.value(normalized);
2774                match (left_value, right_value) {
2775                    (Some(left), Some(right)) if query.selection.reverse => left.cmp(&right),
2776                    (Some(left), Some(right)) => right.cmp(&left),
2777                    (Some(_), None) => std::cmp::Ordering::Less,
2778                    (None, Some(_)) => std::cmp::Ordering::Greater,
2779                    (None, None) => std::cmp::Ordering::Equal,
2780                }
2781            }
2782        };
2783        let ordering = if query.selection.reverse && !matches!(sort_key, SortKey::Metric(_)) {
2784            ordering.reverse()
2785        } else {
2786            ordering
2787        };
2788        ordering.then_with(|| left.language.cmp(&right.language))
2789    });
2790    let before_share = overview.languages.len();
2791    if let Some(threshold) = &query.selection.min_share {
2792        overview.languages.retain(|row| threshold.admits(row.share.numerator, denominator));
2793    }
2794    overview.share_omitted = before_share - overview.languages.len();
2795    overview.total_languages = overview.languages.len();
2796    if let Some(limit) = query.limit_for(ViewSpec::Code).limit() {
2797        overview.languages.truncate(limit);
2798    }
2799    overview
2800}
2801
2802fn merge_coverage(total: &mut BTreeMap<CoverageReason, u64>, row: &BTreeMap<CoverageReason, u64>) {
2803    for (reason, count) in row {
2804        *total.entry(*reason).or_default() += count;
2805    }
2806}
2807
2808/// The denominator every row of a grouped section shares: the sum of all its rows'
2809/// numerators, taken before the share filter and the row bound.
2810///
2811/// It is the rows' sum rather than the total row's own value because the two differ for
2812/// document words. Logical words are derived from pooled statistics
2813/// ([`LogicalWordStats::logical_words`]), whose regime the pool decides, so a section
2814/// mixing formats has a total that differs from the sum of its rows, in either direction.
2815/// Dividing by that total would make the rows' shares sum past 100% beside long-token
2816/// formats and fall short of it beside very short tokens (fdu-ij5n). For bytes, code
2817/// lines, and raw words the two are equal, so this rule changes nothing there.
2818fn share_denominator(rows: &[MetricRow], metric: ShareMetric) -> u64 {
2819    rows.iter().fold(0_u64, |sum, row| sum.saturating_add(share_value(row, metric)))
2820}
2821
2822fn share_value(row: &MetricRow, metric: ShareMetric) -> u64 {
2823    match metric {
2824        ShareMetric::ApparentBytes => row.bytes,
2825        ShareMetric::AllocatedBytes => row.allocated,
2826        ShareMetric::CodeLines => row.metrics.code_lines.unwrap_or(0),
2827        ShareMetric::DocumentWords => document_words(row).unwrap_or(0),
2828        ShareMetric::RawWords => row.metrics.raw_words.unwrap_or(0),
2829    }
2830}
2831
2832/// Derive the selected document volume only after every sufficient statistic is added.
2833pub fn document_words(row: &MetricRow) -> Option<u64> {
2834    row.metrics.document_words
2835}
2836
2837/// Derived page inputs, absent unless the words unit was requested.
2838#[derive(Clone, Copy, PartialEq, Eq, Debug)]
2839pub struct Pages {
2840    /// Query-selected document words.
2841    pub words: u64,
2842    /// Words represented by one page.
2843    pub words_per_page: u64,
2844}
2845
2846/// Build page inputs only when document words were measured.
2847pub fn pages(row: &MetricRow, words_per_page: u64) -> Option<Pages> {
2848    document_words(row).map(|words| Pages { words, words_per_page: words_per_page.max(1) })
2849}
2850
2851/// Rows for the files view.
2852fn file_rows(
2853    view: ViewSpec,
2854    index: &Index,
2855    query: &Query,
2856    content: AnalysisSet,
2857    walked: Option<&Walked>,
2858    unfiltered_rows: Option<&[FileRow]>,
2859) -> (Vec<FileRow>, usize) {
2860    let mut rows = entry_rows(index, walked, unfiltered_rows).into_owned();
2861    if view.files_only() {
2862        rows.retain(|row| row.kind == EntryKind::File);
2863    }
2864    if let Some(SortKey::Metric(name)) = query.selection.sort {
2865        let sources = walked.map_or(rows.as_slice(), |walked| walked.members.as_slice());
2866        let values = metric_sort_values(index, content, sources, name);
2867        for row in &mut rows {
2868            row.sort_value = values.get(&row.path).copied();
2869        }
2870    }
2871
2872    sort_rows(
2873        &mut rows,
2874        query,
2875        view,
2876        SortAccessors {
2877            size: |row: &FileRow, metric| match metric {
2878                SizeMetric::Apparent => row.bytes,
2879                SizeMetric::Allocated => row.allocated,
2880            },
2881            count: |row: &FileRow| row.files.unwrap_or(1),
2882            mtime: |row: &FileRow| Some(row.mtime_ns),
2883            name: borrowed_name(|row: &FileRow| row.path.to_string_lossy()),
2884            content_metric: |row: &FileRow, _: &MetricDef| row.sort_value,
2885        },
2886    );
2887    let total = truncate(&mut rows, query.limit_for(view));
2888    let held = index.content().and_then(|tier| tier.admit(&index.content_identity(content)));
2889    for row in &mut rows {
2890        if row.kind == EntryKind::File {
2891            row.classification = Some(held.and_then(|tier| tier.file(&row.path)).map_or_else(
2892                || index.classify(&row.path).into(),
2893                |record| record.detection.clone(),
2894            ));
2895        }
2896    }
2897    (rows, total)
2898}
2899
2900#[derive(Clone, Copy)]
2901enum MetricMeasure {
2902    Additive(u64),
2903    Normalized(LogicalWordStats),
2904}
2905
2906#[derive(Default)]
2907struct MetricAggregate {
2908    additive: u64,
2909    normalized: LogicalWordStats,
2910    measured: bool,
2911}
2912
2913impl MetricAggregate {
2914    fn add(&mut self, measure: MetricMeasure) {
2915        self.measured = true;
2916        match measure {
2917            MetricMeasure::Additive(value) => {
2918                self.additive = self.additive.saturating_add(value);
2919            }
2920            MetricMeasure::Normalized(stats) => self.normalized.add_assign(stats),
2921        }
2922    }
2923
2924    fn value(&self, normalized: bool) -> Option<u64> {
2925        self.measured.then(
2926            || {
2927                if normalized { self.normalized.logical_words() } else { self.additive }
2928            },
2929        )
2930    }
2931}
2932
2933fn measured_file_metric(
2934    record: &crate::content::FileAnalysis,
2935    file_type: &str,
2936    metric_name: &str,
2937) -> Option<MetricMeasure> {
2938    let direct = |value| Some(MetricMeasure::Additive(value));
2939    match metric_name {
2940        "physical_lines" => direct(record.lines.value()?.physical_lines),
2941        "blank_lines" => direct(record.lines.value()?.blank_lines),
2942        "nonblank_lines" => direct(record.lines.value()?.nonblank_lines),
2943        "raw_words" => direct(record.lines.value()?.raw_words),
2944        "code_lines" => direct(record.code?.value()?.code_lines),
2945        "comment_lines" => direct(record.code?.value()?.comment_lines),
2946        "code_blank_lines" => direct(record.code?.value()?.code_blank_lines),
2947        "paragraphs" => direct(record.words?.value()?.paragraphs),
2948        "visible_words" => direct(record.words?.value()?.visible_words),
2949        "logical_words" => {
2950            Some(MetricMeasure::Normalized(record.words?.value()?.logical_word_stats))
2951        }
2952        "visible_logical_words" => {
2953            Some(MetricMeasure::Normalized(record.words?.value()?.visible_logical_word_stats))
2954        }
2955        "document_words" => {
2956            let words = record.words?.value()?;
2957            Some(MetricMeasure::Normalized(if file_type == "markdown" {
2958                words.visible_logical_word_stats
2959            } else {
2960                words.logical_word_stats
2961            }))
2962        }
2963        _ => None,
2964    }
2965}
2966
2967fn metric_sort_values(
2968    index: &Index,
2969    content: AnalysisSet,
2970    sources: &[FileRow],
2971    metric_name: &str,
2972) -> BTreeMap<PathBuf, u64> {
2973    let held = index.content().and_then(|tier| tier.admit(&index.content_identity(content)));
2974    let mut aggregate = BTreeMap::<PathBuf, MetricAggregate>::new();
2975    for file in sources.iter().filter(|row| row.kind == EntryKind::File) {
2976        let Some(record) = held.and_then(|tier| tier.file(&file.path)) else { continue };
2977        let classification = index.classify(&file.path);
2978        let Some(measure) =
2979            measured_file_metric(record, classification.file_type.as_str(), metric_name)
2980        else {
2981            continue;
2982        };
2983        let mut path = Some(file.path.as_path());
2984        while let Some(current) = path {
2985            if current.as_os_str().is_empty() {
2986                break;
2987            }
2988            aggregate.entry(current.to_path_buf()).or_default().add(measure);
2989            path = current.parent();
2990        }
2991    }
2992    let normalized =
2993        matches!(metric_name, "logical_words" | "visible_logical_words" | "document_words");
2994    aggregate
2995        .into_iter()
2996        .filter_map(|(path, values)| values.value(normalized).map(|value| (path, value)))
2997        .collect()
2998}
2999
3000/// Every entry in the index, for an unfiltered files view.
3001fn every_entry(index: &Index) -> Vec<FileRow> {
3002    debug_assert!(!index.is_folded(), "a folded index keeps too few files to list");
3003    let mut rows = Vec::new();
3004    let mut stack: Vec<(EntryId, PathBuf)> = vec![(EntryId::ROOT, PathBuf::new())];
3005    while let Some((id, path)) = stack.pop() {
3006        let Some(children) = index.children_of(id) else {
3007            continue;
3008        };
3009        let children: Vec<(PathBuf, EntryId)> =
3010            children.map(|(name, child)| (path.join(name), child)).collect();
3011        for (child_path, child) in children {
3012            let (Some(kind), Some(attrs)) = (index.kind_of(child), index.attrs_of(child)) else {
3013                continue;
3014            };
3015            rows.push(FileRow {
3016                path: child_path.clone(),
3017                kind,
3018                bytes: attrs.size,
3019                allocated: attrs.allocated,
3020                mtime_ns: attrs.mtime_ns,
3021                files: None,
3022                dirs: None,
3023                complete: None,
3024                age_ns: None,
3025                ignored: index.ignored_classification_of(&child_path, child),
3026                sort_value: None,
3027                classification: None,
3028            });
3029            if kind == EntryKind::Dir {
3030                stack.push((child, child_path));
3031            }
3032        }
3033    }
3034    rows
3035}
3036
3037/// The tree view's root node, expanded to the requested depth.
3038fn tree_node(
3039    index: &Index,
3040    query: &Query,
3041    content: AnalysisSet,
3042    walked: Option<&Walked>,
3043    tree_measurements: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
3044) -> (Option<TreeNode>, Vec<TreeOmission>) {
3045    let unfiltered = (walked.is_none() && matches!(query.selection.sort, Some(SortKey::Metric(_))))
3046        .then(|| every_entry(index));
3047    let metric_values = if let Some(SortKey::Metric(name)) = query.selection.sort {
3048        let sources = walked.map_or_else(
3049            || unfiltered.as_deref().unwrap_or(&[]),
3050            |walked| walked.members.as_slice(),
3051        );
3052        metric_sort_values(index, content, sources, name)
3053    } else {
3054        BTreeMap::new()
3055    };
3056    let root_summary = match walked {
3057        None => unfiltered_summary(index, EntryId::ROOT, Path::new("")),
3058        Some(walked) => walked.summary_of(EntryId::ROOT),
3059    };
3060
3061    let mut root = TreeNode {
3062        path: PathBuf::new(),
3063        name: ".".to_string(),
3064        kind: EntryKind::Dir,
3065        entry_ignored: index.ignored_classification_of(Path::new(""), EntryId::ROOT),
3066        bytes: root_summary.bytes,
3067        allocated: root_summary.allocated,
3068        files: root_summary.files,
3069        dirs: root_summary.dirs,
3070        ignored: root_summary.ignored,
3071        newest_mtime_ns: root_summary.newest_mtime_ns,
3072        children: Vec::new(),
3073        omissions: Vec::new(),
3074        truncated: false,
3075    };
3076    if query.limit_for(ViewSpec::Tree) == Bound::Limit(0) {
3077        let complete = index.state().coverage == crate::Coverage::Complete;
3078        let omitted = TreeOmission {
3079            reason: TreeOmissionReason::Rows,
3080            entries: 1,
3081            files: complete.then_some(root.files),
3082            bytes: complete.then_some(root.bytes),
3083            allocated: complete.then_some(root.allocated),
3084            ignored: complete.then_some(root.ignored).flatten().map(IgnoredSize::from_tally),
3085        };
3086        return (None, vec![omitted]);
3087    }
3088    expand(index, query, walked, &metric_values, tree_measurements, &mut root);
3089    if let Some(cap) = query.limit_for(ViewSpec::Tree).limit() {
3090        root = cap_tree_rows(root, cap, index.state().coverage == crate::Coverage::Complete);
3091    }
3092    (Some(root), Vec::new())
3093}
3094
3095/// The files a folded index counted in one directory without keeping them, as the rows
3096/// of one file each that a share threshold omits there: every such file is below it.
3097#[derive(Clone, Copy)]
3098struct FoldedRows {
3099    entries: usize,
3100    files: u64,
3101    bytes: u64,
3102    allocated: u64,
3103    /// Their ignored part, `None` exactly where a kept file's row in the same directory
3104    /// would carry no classification ([`Index::children_classification_known`]).
3105    ignored: Option<IgnoredSize>,
3106}
3107
3108impl FoldedRows {
3109    /// No rows at all.
3110    const NONE: Self = Self {
3111        entries: 0,
3112        files: 0,
3113        bytes: 0,
3114        allocated: 0,
3115        ignored: Some(IgnoredSize { bytes: 0, allocated: 0 }),
3116    };
3117
3118    /// The folded rows of directory `id` at `path`, if its index folded any there.
3119    fn of(index: &Index, id: EntryId, path: &Path) -> Option<Self> {
3120        let folded = index.folded_children(id)?;
3121        Some(Self {
3122            entries: usize::try_from(folded.files).unwrap_or(usize::MAX),
3123            files: folded.files,
3124            bytes: folded.bytes,
3125            allocated: folded.allocated,
3126            ignored: index.children_classification_known(path).then_some(folded.ignored),
3127        })
3128    }
3129}
3130
3131/// What an omitted row contributes to its omission: the scalars of a [`TreeNode`],
3132/// without the path and name a node also carries. A row the share threshold omits is
3133/// counted here without ever becoming a node (H186).
3134#[derive(Clone, Copy)]
3135struct RowFacts {
3136    files: u64,
3137    bytes: u64,
3138    allocated: u64,
3139    ignored: Option<IgnoredTally>,
3140}
3141
3142impl From<&TreeNode> for RowFacts {
3143    fn from(node: &TreeNode) -> Self {
3144        Self {
3145            files: node.files,
3146            bytes: node.bytes,
3147            allocated: node.allocated,
3148            ignored: node.ignored,
3149        }
3150    }
3151}
3152
3153fn facts_of(rows: &[(TreeNode, EntryId)]) -> Vec<RowFacts> {
3154    rows.iter().map(|(row, _)| RowFacts::from(row)).collect()
3155}
3156
3157fn record_omission(
3158    node: &mut TreeNode,
3159    reason: TreeOmissionReason,
3160    rows: &[RowFacts],
3161    folded: Option<FoldedRows>,
3162    complete: bool,
3163) {
3164    if rows.is_empty() && folded.is_none() {
3165        return;
3166    }
3167    // Folded rows are summed first. Every term is unsigned, so a checked sum overflows
3168    // exactly when the sum of all of them does, in whatever order they are added.
3169    let seed = folded.unwrap_or(FoldedRows::NONE);
3170    let bytes = complete
3171        .then(|| rows.iter().try_fold(seed.bytes, |sum, row| sum.checked_add(row.bytes)))
3172        .flatten();
3173    let allocated = complete
3174        .then(|| rows.iter().try_fold(seed.allocated, |sum, row| sum.checked_add(row.allocated)))
3175        .flatten();
3176    let files = complete
3177        .then(|| rows.iter().try_fold(seed.files, |sum, row| sum.checked_add(row.files)))
3178        .flatten();
3179    let ignored = complete
3180        .then(|| {
3181            rows.iter().try_fold(seed.ignored?, |sum, row| {
3182                sum.checked_add(IgnoredSize::from_tally(row.ignored?))
3183            })
3184        })
3185        .flatten();
3186    node.omissions.push(TreeOmission {
3187        reason,
3188        entries: rows.len().saturating_add(seed.entries),
3189        files,
3190        bytes,
3191        allocated,
3192        ignored,
3193    });
3194    node.truncated = true;
3195}
3196
3197fn cap_tree_rows(root: TreeNode, cap: usize, complete: bool) -> TreeNode {
3198    struct Pending {
3199        node: TreeNode,
3200        parent: Option<usize>,
3201    }
3202    let mut pending = vec![Pending { node: root, parent: None }];
3203    let mut kept: Vec<Pending> = Vec::new();
3204    while let Some(mut item) = pending.pop() {
3205        if kept.len() == cap {
3206            let parent = item.parent.expect("root admitted by positive row cap");
3207            let omission = TreeOmission {
3208                reason: TreeOmissionReason::Rows,
3209                entries: 1,
3210                files: complete.then_some(item.node.files),
3211                bytes: complete.then_some(item.node.bytes),
3212                allocated: complete.then_some(item.node.allocated),
3213                ignored: complete
3214                    .then_some(item.node.ignored)
3215                    .flatten()
3216                    .map(IgnoredSize::from_tally),
3217            };
3218            let owner = &mut kept[parent].node;
3219            if let Some(existing) = owner
3220                .omissions
3221                .iter_mut()
3222                .find(|existing| existing.reason == TreeOmissionReason::Rows)
3223            {
3224                existing.entries += 1;
3225                existing.files =
3226                    existing.files.zip(omission.files).and_then(|(a, b)| a.checked_add(b));
3227                existing.bytes =
3228                    existing.bytes.zip(omission.bytes).and_then(|(a, b)| a.checked_add(b));
3229                existing.allocated =
3230                    existing.allocated.zip(omission.allocated).and_then(|(a, b)| a.checked_add(b));
3231                existing.ignored =
3232                    existing.ignored.zip(omission.ignored).and_then(|(a, b)| a.checked_add(b));
3233            } else {
3234                owner.omissions.push(omission);
3235            }
3236            owner.truncated = true;
3237            continue;
3238        }
3239        let children = std::mem::take(&mut item.node.children);
3240        let current = kept.len();
3241        kept.push(item);
3242        for child in children.into_iter().rev() {
3243            pending.push(Pending { node: child, parent: Some(current) });
3244        }
3245    }
3246    for position in (1..kept.len()).rev() {
3247        let child = kept.remove(position);
3248        kept[child.parent.expect("only root lacks parent")].node.children.insert(0, child.node);
3249    }
3250    kept.pop().expect("positive cap admits root").node
3251}
3252
3253/// Attach a node's children, honoring the depth and per-directory limit bounds.
3254///
3255/// Iterative rather than recursive: this engine indexes trees deep enough that recursive
3256/// expansion would exhaust the stack, and a report that panics on a deep tree fails
3257/// exactly where the tool is most useful. Nodes are built flat with parent links in
3258/// pre-order, then folded together from the leaves up.
3259fn expand(
3260    index: &Index,
3261    query: &Query,
3262    walked: Option<&Walked>,
3263    metric_values: &BTreeMap<PathBuf, u64>,
3264    tree_measurements: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
3265    node: &mut TreeNode,
3266) {
3267    /// One node awaiting its children.
3268    struct Pending {
3269        node: TreeNode,
3270        id: EntryId,
3271        depth: usize,
3272        parent: Option<usize>,
3273    }
3274
3275    let mut built = vec![Pending {
3276        // Only identity and bounds matter while expanding; the caller keeps the
3277        // populated root and receives its children back at the end.
3278        node: TreeNode {
3279            path: node.path.clone(),
3280            name: node.name.clone(),
3281            kind: node.kind,
3282            entry_ignored: node.entry_ignored,
3283            bytes: node.bytes,
3284            allocated: node.allocated,
3285            files: node.files,
3286            dirs: node.dirs,
3287            ignored: node.ignored,
3288            newest_mtime_ns: node.newest_mtime_ns,
3289            children: Vec::new(),
3290            omissions: Vec::new(),
3291            truncated: false,
3292        },
3293        id: EntryId::ROOT,
3294        depth: 0,
3295        parent: None,
3296    }];
3297
3298    let threshold = query.min_share_for();
3299    let grand = match query.selection.size {
3300        SizeMetric::Apparent => node.bytes,
3301        SizeMetric::Allocated => node.allocated,
3302    };
3303    let complete = index.state().coverage == crate::Coverage::Complete;
3304    let mut cursor = 0;
3305    while cursor < built.len() {
3306        let (id, depth) = (built[cursor].id, built[cursor].depth);
3307        let path = built[cursor].node.path.clone();
3308
3309        let (mut rows, below_share) = child_rows(
3310            index,
3311            query,
3312            walked,
3313            metric_values,
3314            tree_measurements,
3315            id,
3316            &path,
3317            &threshold,
3318            grand,
3319        );
3320        // A folded index kept only files that can reach the share, so the files it folded
3321        // here are rows below it as well.
3322        record_omission(
3323            &mut built[cursor].node,
3324            TreeOmissionReason::Share,
3325            &below_share,
3326            FoldedRows::of(index, id, &path),
3327            true,
3328        );
3329        if !query.depth_for(ViewSpec::Tree).admits(depth) {
3330            record_omission(
3331                &mut built[cursor].node,
3332                TreeOmissionReason::Depth,
3333                &facts_of(&rows),
3334                None,
3335                complete,
3336            );
3337            cursor += 1;
3338            continue;
3339        }
3340        if let Some(cap) = query.breadth_for().limit() {
3341            let hidden = rows.split_off(cap.min(rows.len()));
3342            record_omission(
3343                &mut built[cursor].node,
3344                TreeOmissionReason::Breadth,
3345                &facts_of(&hidden),
3346                None,
3347                complete,
3348            );
3349        }
3350
3351        for (child_node, child_id) in rows {
3352            built.push(Pending {
3353                node: child_node,
3354                id: child_id,
3355                depth: depth + 1,
3356                parent: Some(cursor),
3357            });
3358        }
3359        cursor += 1;
3360    }
3361
3362    // Fold from the end: every parent index is smaller than its child's, so removing the
3363    // last element never disturbs an index still to be used.
3364    for position in (1..built.len()).rev() {
3365        let child = built.remove(position);
3366        let parent = child.parent.expect("only the root has no parent");
3367        built[parent].node.children.insert(0, child.node);
3368    }
3369
3370    let mut root = built.pop().expect("the root is always present");
3371    node.children = std::mem::take(&mut root.node.children);
3372    node.omissions = std::mem::take(&mut root.node.omissions);
3373    node.truncated = root.node.truncated;
3374}
3375
3376/// The directory children of one node that the share threshold admits, shaped and
3377/// sorted but not yet expanded, beside the facts of those it omits.
3378///
3379/// Each child's roll-up decides its admission before anything else is built for it
3380/// (H186): a node carries a path and a name, and the share omits most children of a wide
3381/// directory, so the report used to build, sort, clone and free a node for every child
3382/// it would never show. The omitted children are summed by [`record_omission`] from their
3383/// facts alone. Admitting before sorting orders the admitted rows exactly as sorting all
3384/// of them would, since names are unique within a directory.
3385#[allow(clippy::too_many_arguments)]
3386fn child_rows(
3387    index: &Index,
3388    query: &Query,
3389    walked: Option<&Walked>,
3390    metric_values: &BTreeMap<PathBuf, u64>,
3391    tree_measurements: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
3392    id: EntryId,
3393    path: &Path,
3394    threshold: &ShareThreshold,
3395    grand: u64,
3396) -> (Vec<(TreeNode, EntryId)>, Vec<RowFacts>) {
3397    let Some(children) = index.children_of(id) else {
3398        return (Vec::new(), Vec::new());
3399    };
3400    // Every child's classification is known exactly when its parent's controls are.
3401    let children_known = index.children_classification_known(path);
3402    let mut child_path = path.to_path_buf();
3403    let mut rows: Vec<(TreeNode, EntryId)> = Vec::new();
3404    let mut below_share: Vec<RowFacts> = Vec::new();
3405    for (name, child) in children {
3406        let Some(kind) = index.kind_of(child) else {
3407            continue;
3408        };
3409        if !matches!(kind, EntryKind::Dir | EntryKind::File) {
3410            continue;
3411        }
3412        if walked.is_some_and(|walked| match kind {
3413            EntryKind::Dir => !walked.visible.contains(&child),
3414            EntryKind::File => !walked.visible_files.contains(&child),
3415            EntryKind::Symlink | EntryKind::Other => true,
3416        }) {
3417            continue;
3418        }
3419        let entry_ignored = children_known.then(|| index.entry_ignored(child)).flatten();
3420        child_path.push(name);
3421        let summary = if kind == EntryKind::File {
3422            let attrs = index.attrs_of(child).expect("live child has attributes");
3423            let ignored = entry_ignored.map(|ignored| {
3424                if ignored {
3425                    IgnoredTally {
3426                        files: 1,
3427                        dirs: 0,
3428                        bytes: attrs.size,
3429                        allocated: attrs.allocated,
3430                    }
3431                } else {
3432                    IgnoredTally::default()
3433                }
3434            });
3435            SummaryRow {
3436                files: 1,
3437                dirs: 0,
3438                bytes: attrs.size,
3439                allocated: attrs.allocated,
3440                ignored,
3441                newest_mtime_ns: Some(attrs.mtime_ns),
3442            }
3443        } else {
3444            match walked {
3445                None => unfiltered_summary(index, child, &child_path),
3446                Some(walked) => walked.summary_of(child),
3447            }
3448        };
3449        let value = match query.selection.size {
3450            SizeMetric::Apparent => summary.bytes,
3451            SizeMetric::Allocated => summary.allocated,
3452        };
3453        // A complete child below a partial root's observed total is also below the true
3454        // (at least as large) total. Only an incomplete child's own unknown contents
3455        // prevent that proof; unrelated scan errors do not.
3456        let child_complete = kind == EntryKind::File
3457            || tree_measurements
3458                .is_none_or(|values| values.get(&child).is_some_and(|subtree| subtree.complete));
3459        if child_complete && !threshold.admits(value, grand) {
3460            below_share.push(RowFacts {
3461                files: summary.files,
3462                bytes: summary.bytes,
3463                allocated: summary.allocated,
3464                ignored: summary.ignored,
3465            });
3466            child_path.pop();
3467            continue;
3468        }
3469        rows.push((
3470            TreeNode {
3471                path: child_path.clone(),
3472                name: name.to_string_lossy().into_owned(),
3473                kind,
3474                entry_ignored,
3475                bytes: summary.bytes,
3476                allocated: summary.allocated,
3477                files: summary.files,
3478                dirs: summary.dirs,
3479                ignored: summary.ignored,
3480                newest_mtime_ns: summary.newest_mtime_ns,
3481                children: Vec::new(),
3482                omissions: Vec::new(),
3483                truncated: false,
3484            },
3485            child,
3486        ));
3487        child_path.pop();
3488    }
3489
3490    sort_rows_by(
3491        &mut rows,
3492        query,
3493        ViewSpec::Tree,
3494        SortAccessors {
3495            size: |(row, _): &(TreeNode, EntryId), metric| match metric {
3496                SizeMetric::Apparent => row.bytes,
3497                SizeMetric::Allocated => row.allocated,
3498            },
3499            count: |(row, _): &(TreeNode, EntryId)| row.files,
3500            mtime: |(row, _): &(TreeNode, EntryId)| row.newest_mtime_ns,
3501            name: borrowed_name(|(row, _): &(TreeNode, EntryId)| {
3502                std::borrow::Cow::Borrowed(row.name.as_str())
3503            }),
3504            content_metric: |(row, _): &(TreeNode, EntryId), _: &MetricDef| {
3505                metric_values.get(&row.path).copied()
3506            },
3507        },
3508    );
3509    (rows, below_share)
3510}
3511
3512/// Trim a row list to the configured limit.
3513fn truncate<T>(rows: &mut Vec<T>, limit: Bound) -> usize {
3514    let total = rows.len();
3515    if let Some(limit) = limit.limit() {
3516        rows.truncate(limit);
3517    }
3518    total
3519}
3520
3521/// Sort rows by the effective key for a view.
3522struct SortAccessors<S, C, M, N, V> {
3523    size: S,
3524    count: C,
3525    mtime: M,
3526    name: N,
3527    content_metric: V,
3528}
3529
3530fn sort_rows<T>(
3531    rows: &mut [T],
3532    query: &Query,
3533    view: ViewSpec,
3534    accessors: SortAccessors<
3535        impl Fn(&T, SizeMetric) -> u64,
3536        impl Fn(&T) -> u64,
3537        impl Fn(&T) -> Option<i64>,
3538        impl for<'r> Fn(&'r T) -> std::borrow::Cow<'r, str>,
3539        impl Fn(&T, &MetricDef) -> Option<u64>,
3540    >,
3541) {
3542    sort_rows_by(rows, query, view, accessors);
3543}
3544
3545/// A name accessor that borrows from its row, given the higher-ranked signature a closure
3546/// written inside a [`SortAccessors`] literal is not inferred to have.
3547fn borrowed_name<T, F>(name: F) -> F
3548where
3549    F: for<'r> Fn(&'r T) -> std::borrow::Cow<'r, str>,
3550{
3551    name
3552}
3553
3554/// Sort rows by the effective key, with a stable name tiebreak.
3555///
3556/// The name accessor borrows: the tiebreak runs once per compared pair, and a sort that
3557/// cloned two `String`s for each was 4.6G instructions on an 80k-row listing (H186).
3558fn sort_rows_by<T>(
3559    rows: &mut [T],
3560    query: &Query,
3561    view: ViewSpec,
3562    accessors: SortAccessors<
3563        impl Fn(&T, SizeMetric) -> u64,
3564        impl Fn(&T) -> u64,
3565        impl Fn(&T) -> Option<i64>,
3566        impl for<'r> Fn(&'r T) -> std::borrow::Cow<'r, str>,
3567        impl Fn(&T, &MetricDef) -> Option<u64>,
3568    >,
3569) {
3570    let SortAccessors { size, count, mtime, name, content_metric } = accessors;
3571    let key = query.selection.sort.unwrap_or_else(|| view.default_sort());
3572    let metric = query.selection.size;
3573
3574    rows.sort_by(|left, right| {
3575        let ordering = match key {
3576            // Size, count, and recency read most-first: the interesting end is the top.
3577            SortKey::Size => size(right, metric).cmp(&size(left, metric)),
3578            SortKey::Count => count(right).cmp(&count(left)),
3579            SortKey::Mtime => mtime(right).cmp(&mtime(left)),
3580            SortKey::Name => name(left).cmp(&name(right)),
3581            SortKey::Metric(metric_name) => {
3582                let definition =
3583                    crate::content::METRICS.iter().find(|entry| entry.name == metric_name);
3584                let left_value = definition.and_then(|definition| content_metric(left, definition));
3585                let right_value =
3586                    definition.and_then(|definition| content_metric(right, definition));
3587                match (left_value, right_value) {
3588                    (Some(left), Some(right)) if query.selection.reverse => left.cmp(&right),
3589                    (Some(left), Some(right)) => right.cmp(&left),
3590                    (Some(_), None) => std::cmp::Ordering::Less,
3591                    (None, Some(_)) => std::cmp::Ordering::Greater,
3592                    (None, None) => std::cmp::Ordering::Equal,
3593                }
3594            }
3595        };
3596        // A name tiebreak keeps equal rows in a deterministic order, which is what makes
3597        // the goldens stable across runs and platforms.
3598        ordering.then_with(|| name(left).cmp(&name(right)))
3599    });
3600
3601    if query.selection.reverse && !matches!(key, SortKey::Metric(_)) {
3602        rows.reverse();
3603    }
3604}
3605
3606#[cfg(test)]
3607mod tests {
3608    use super::*;
3609    use crate::engine_contract::{Attrs, Observation, Op};
3610    use crate::query::query_glob::Pattern;
3611    use crate::query::query_selection::ModifiedWindow;
3612    use std::fs;
3613    use std::time::{Duration, UNIX_EPOCH};
3614
3615    fn attrs(size: u64, mtime_ns: i64) -> Attrs {
3616        Attrs {
3617            size,
3618            allocated: size.div_ceil(512) * 512,
3619            mtime_ns,
3620            ctime_ns: mtime_ns,
3621            inode: size.wrapping_mul(31).wrapping_add(mtime_ns.unsigned_abs()),
3622            dev: 1,
3623        }
3624    }
3625
3626    fn upsert(path: &str, kind: EntryKind, attrs: Attrs) -> Op {
3627        Op::Upsert { path: PathBuf::from(path), kind, attrs }
3628    }
3629
3630    /// A tree with two top-level directories, a nested level, and three extensions.
3631    fn sample() -> Index {
3632        let mut index = Index::new("/root");
3633        index
3634            .apply(&Observation::new(vec![
3635                upsert("src", EntryKind::Dir, Attrs::default()),
3636                upsert("src/main.rs", EntryKind::File, attrs(100, 10)),
3637                upsert("src/lib.rs", EntryKind::File, attrs(200, 20)),
3638                upsert("src/deep", EntryKind::Dir, Attrs::default()),
3639                upsert("src/deep/nested.rs", EntryKind::File, attrs(50, 40)),
3640                upsert("docs", EntryKind::Dir, Attrs::default()),
3641                upsert("docs/guide.md", EntryKind::File, attrs(300, 30)),
3642                upsert("notes.txt", EntryKind::File, attrs(7, 5)),
3643            ]))
3644            .expect("apply");
3645        index
3646    }
3647
3648    #[test]
3649    fn ages_use_one_signed_reference_and_unrepresentable_clocks_are_unknown() {
3650        let mut index = sample();
3651        index.apply_ok(&Observation::new(vec![
3652            upsert("past", EntryKind::File, attrs(1, -10)),
3653            upsert("future", EntryKind::File, attrs(1, i64::MAX)),
3654        ]));
3655        let query = query(
3656            &[ViewSpec::Files],
3657            Selection { include: vec![pattern("past"), pattern("future")], ..Selection::default() },
3658        );
3659        let mut request = Request::new(Basis::held_by(&index), query, UNIX_EPOCH);
3660        let answer = report(&index, &request, UNIX_EPOCH).expect("report");
3661        let rows = files_of(&answer);
3662        assert_eq!(answer.age_reference_ns, Some(0));
3663        assert_eq!(
3664            rows.iter().map(|r| r.age_ns).collect::<Vec<_>>(),
3665            [Some(-i128::from(i64::MAX)), Some(10)]
3666        );
3667        request.now = UNIX_EPOCH + Duration::from_secs(10_000_000_000);
3668        let answer = report(&index, &request, UNIX_EPOCH)
3669            .expect("out-of-range reference is representable as unknown age");
3670        assert_eq!(answer.age_reference_ns, None);
3671        assert!(files_of(&answer).iter().all(|row| row.age_ns.is_none()));
3672    }
3673
3674    #[test]
3675    fn matching_a_directory_selects_its_subtree_once() {
3676        let index = sample();
3677        let selection = Selection {
3678            include: vec![pattern("src"), pattern("deep")],
3679            kinds: vec![EntryKind::Dir],
3680            size: SizeMetric::Apparent,
3681            min_size: Some(40),
3682            ..Selection::default()
3683        };
3684        let report = run(
3685            &index,
3686            &query(&[ViewSpec::Files, ViewSpec::Summary, ViewSpec::Extensions], selection),
3687        );
3688        let rows = files_of(&report);
3689        assert_eq!(rows.len(), 2);
3690        assert_eq!((rows[0].bytes, rows[0].mtime_ns), (350, 40));
3691        let Section::Summary(summary) = &report.sections[1] else { panic!("summary") };
3692        assert_eq!((summary.files, summary.dirs, summary.bytes), (3, 2, 350));
3693        let Section::Extensions { rows, .. } = &report.sections[2] else { panic!("extensions") };
3694        assert_eq!((rows[0].files, rows[0].bytes), (3, 350));
3695    }
3696
3697    #[test]
3698    fn a_selection_over_an_exactly_full_tree_sums_to_u64_max() {
3699        // Every route that builds an index refuses a total a u64 cannot hold (fdu-sqyk),
3700        // so every selected subset of an index's tree fits too; the filtered tier
3701        // re-aggregates entry by entry and must reach the exact bound without saturating
3702        // or wrapping.
3703        let exact =
3704            |size: u64, mtime_ns: i64| Attrs { size, allocated: size, ..attrs(1, mtime_ns) };
3705        let mut index = Index::new("/root");
3706        index.apply_ok(&Observation::new(vec![
3707            upsert("big", EntryKind::Dir, Attrs::default()),
3708            upsert("big/half.bin", EntryKind::File, exact(1 << 63, 1)),
3709            upsert("big/rest.bin", EntryKind::File, exact((1 << 63) - 1, 2)),
3710            upsert("empty.txt", EntryKind::File, exact(0, 3)),
3711        ]));
3712        assert_eq!(index.total().bytes, u64::MAX);
3713        let selection = Selection {
3714            include: vec![pattern("big")],
3715            size: SizeMetric::Apparent,
3716            min_size: Some(1),
3717            ..Selection::default()
3718        };
3719        let report = run(&index, &query(&[ViewSpec::Summary, ViewSpec::Files], selection));
3720        let Section::Summary(summary) = &report.sections[0] else { panic!("summary") };
3721        assert_eq!((summary.files, summary.dirs, summary.bytes), (2, 1, u64::MAX));
3722        let Section::Files { rows, .. } = &report.sections[1] else { panic!("files") };
3723        assert_eq!(rows.iter().map(|row| row.bytes).sum::<u64>(), u64::MAX);
3724    }
3725
3726    #[test]
3727    fn subtree_predicates_include_directory_and_symlink_activity_but_only_file_bytes() {
3728        let mut index = sample();
3729        index
3730            .apply(&Observation::new(vec![
3731                upsert("src", EntryKind::Dir, attrs(9999, 45)),
3732                upsert("src/empty", EntryKind::Dir, attrs(8888, 60)),
3733                upsert("src/link", EntryKind::Symlink, attrs(7777, 70)),
3734                upsert("empty", EntryKind::Dir, attrs(6666, -10)),
3735            ]))
3736            .expect("apply");
3737        let base = Selection {
3738            include: vec![pattern("src"), pattern("empty")],
3739            size: SizeMetric::Apparent,
3740            ..Selection::default()
3741        };
3742        // A one-shot report matches native names, so the nested spelling is joined rather
3743        // than written with a literal separator: `src/empty` holds only on Unix.
3744        let nested = PathBuf::from("src").join("empty").to_string_lossy().into_owned();
3745        let rows = files_of(&run(&index, &query(&[ViewSpec::Files], base.clone())));
3746        assert_eq!(
3747            rows.iter()
3748                .map(|r| (r.path.to_string_lossy().into_owned(), r.bytes, r.mtime_ns))
3749                .collect::<Vec<_>>(),
3750            [("empty".into(), 0, -10), ("src".into(), 350, 70), (nested.clone(), 0, 60)]
3751        );
3752        for (before, since, expected) in [
3753            (70, 0, vec![nested.clone()]),
3754            (71, 70, vec!["src".to_owned()]),
3755            (0, -10, vec!["empty".to_owned()]),
3756        ] {
3757            let selection = Selection {
3758                modified: ModifiedWindow { before: Some(before), since: Some(since) },
3759                ..base.clone()
3760            };
3761            let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
3762            assert_eq!(
3763                rows.iter().map(|r| r.path.to_string_lossy().into_owned()).collect::<Vec<_>>(),
3764                expected
3765            );
3766        }
3767        for (size, minimum, expected) in [
3768            (SizeMetric::Apparent, 350, 1),
3769            (SizeMetric::Apparent, 351, 0),
3770            (SizeMetric::Allocated, 1536, 1),
3771            (SizeMetric::Allocated, 1537, 0),
3772        ] {
3773            let selection = Selection { size, min_size: Some(minimum), ..base.clone() };
3774            assert_eq!(
3775                files_of(&run(&index, &query(&[ViewSpec::Files], selection))).len(),
3776                expected
3777            );
3778        }
3779    }
3780
3781    #[test]
3782    fn exclusions_apply_before_subtree_bounds_and_selected_ancestor_coverage() {
3783        let index = classified_sample();
3784        for (ignored, exclude, bytes, newest) in [
3785            (IgnoredEntries::Include, vec![], 325, 70),
3786            (IgnoredEntries::Exclude, vec![], 300, 20),
3787            (IgnoredEntries::Include, vec![pattern("*.log")], 300, 20),
3788            (IgnoredEntries::Include, vec![pattern("lib.rs")], 125, 70),
3789        ] {
3790            let selection = Selection {
3791                include: vec![pattern("src")],
3792                kinds: vec![EntryKind::Dir],
3793                ignored,
3794                exclude,
3795                size: SizeMetric::Apparent,
3796                ..Selection::default()
3797            };
3798            let report = run(
3799                &index,
3800                &query(&[ViewSpec::Files, ViewSpec::Summary, ViewSpec::Types], selection),
3801            );
3802            let row = &files_of(&report)[0];
3803            assert_eq!((row.bytes, row.mtime_ns), (bytes, newest));
3804            let Section::Summary(summary) = &report.sections[1] else { panic!("summary") };
3805            assert_eq!(summary.bytes, bytes);
3806            let Section::Metrics { summary, .. } = &report.sections[2] else { panic!("types") };
3807            assert_eq!(summary.rows.iter().map(|r| r.bytes).sum::<u64>(), bytes);
3808        }
3809        let selection = Selection {
3810            include: vec![pattern("build")],
3811            exclude: vec![pattern("cache")],
3812            kinds: vec![EntryKind::Dir],
3813            ..Selection::default()
3814        };
3815        let report = run(&index, &query(&[ViewSpec::Files, ViewSpec::Summary], selection));
3816        assert_eq!(files_of(&report)[0].bytes, 0);
3817        let Section::Summary(summary) = &report.sections[1] else { panic!("summary") };
3818        assert_eq!((summary.files, summary.dirs, summary.bytes), (0, 1, 0));
3819        let only = Selection {
3820            include: vec![pattern("cache")],
3821            kinds: vec![EntryKind::Dir],
3822            ignored: IgnoredEntries::Only,
3823            ..Selection::default()
3824        };
3825        assert_eq!(files_of(&run(&index, &query(&[ViewSpec::Files], only)))[0].bytes, 1000);
3826    }
3827
3828    /// A directory whose subtree was not listed in full says so, and its lower-bound
3829    /// activity is not an age: it matches no modification bound, in either direction,
3830    /// while a size bound still holds on the lower bound it can prove.
3831    #[test]
3832    fn an_incomplete_subtree_reports_lower_bounds_and_matches_no_time_bound() {
3833        // `--scan-depth 2`: `env/lib` sits at the boundary, retained and never listed, so
3834        // `env` is incomplete; `docs` holds only files at that depth and is complete.
3835        let mut index = Index::new_with_scope(
3836            "/root",
3837            crate::ScanScope { max_depth: Some(2), ..crate::ScanScope::default() },
3838        );
3839        index.apply_ok(&Observation::new(vec![
3840            upsert("env", EntryKind::Dir, attrs(0, 5)),
3841            upsert("env/lib", EntryKind::Dir, attrs(0, 7)),
3842            upsert("env/a.bin", EntryKind::File, attrs(100, 40)),
3843            upsert("docs", EntryKind::Dir, attrs(0, 5)),
3844            upsert("docs/guide.md", EntryKind::File, attrs(30, 50)),
3845        ]));
3846        let directories = |selection: Selection| {
3847            let selection =
3848                Selection { kinds: vec![EntryKind::Dir], size: SizeMetric::Apparent, ..selection };
3849            files_of(&run(&index, &flat(selection)))
3850                .into_iter()
3851                .map(|row| {
3852                    (row.path.to_string_lossy().into_owned(), row.complete, row.bytes, row.age_ns)
3853                })
3854                .collect::<Vec<_>>()
3855        };
3856        // Joined natively, as the report joins it: Windows spells this row `env\lib`.
3857        let env_lib = Path::new("env").join("lib").to_string_lossy().into_owned();
3858        assert_eq!(
3859            directories(Selection::default()),
3860            vec![
3861                ("env".to_string(), Some(false), 100, None),
3862                ("docs".to_string(), Some(true), 30, Some(-50)),
3863                (env_lib, Some(false), 0, None),
3864            ],
3865            "sizes are lower bounds and the age is unknown below the boundary"
3866        );
3867        for modified in [
3868            ModifiedWindow { since: None, before: Some(100) },
3869            ModifiedWindow { since: Some(0), before: None },
3870        ] {
3871            assert_eq!(
3872                directories(Selection { modified, ..Selection::default() }),
3873                vec![("docs".to_string(), Some(true), 30, Some(-50))],
3874                "an unknown age satisfies no bound, not even one its lower bound would prove"
3875            );
3876        }
3877        assert_eq!(
3878            directories(Selection { min_size: Some(100), ..Selection::default() }),
3879            vec![("env".to_string(), Some(false), 100, None)],
3880            "a lower bound at or above the minimum proves the true size is too"
3881        );
3882        assert!(directories(Selection { min_size: Some(101), ..Selection::default() }).is_empty());
3883        // A regular file has no subtree to be incomplete, and its own age stands.
3884        let files = files_of(&run(
3885            &index,
3886            &flat(Selection {
3887                kinds: vec![EntryKind::File],
3888                modified: ModifiedWindow { since: Some(45), before: None },
3889                ..Selection::default()
3890            }),
3891        ));
3892        assert_eq!(files.len(), 1);
3893        assert_eq!((files[0].complete, files[0].age_ns), (None, Some(-50)));
3894    }
3895
3896    /// A legacy unscoped partial marker carries no authoritative child-list evidence,
3897    /// so no directory row can claim completeness. Scoped scan failures preserve healthy
3898    /// siblings, as the public partial-directory integration test proves.
3899    #[test]
3900    fn an_unscoped_partial_marker_marks_every_directory_row_incomplete() {
3901        let directories = |index: &Index| {
3902            files_of(&run(
3903                index,
3904                &flat(Selection { kinds: vec![EntryKind::Dir], ..Selection::default() }),
3905            ))
3906        };
3907        let mut partial = sample();
3908        partial.set_initial_freshness(false);
3909        let rows = directories(&partial);
3910        assert_eq!(rows.len(), 3);
3911        assert!(rows.iter().all(|row| row.complete == Some(false) && row.age_ns.is_none()));
3912        let mut complete = sample();
3913        complete.set_initial_freshness(true);
3914        let rows = directories(&complete);
3915        assert_eq!(rows.len(), 3);
3916        assert!(rows.iter().all(|row| row.complete == Some(true) && row.age_ns.is_some()));
3917    }
3918
3919    /// While an opened root is discovering, a directory is complete exactly when
3920    /// discovery has listed it, so a report served mid-discovery marks the rest.
3921    #[test]
3922    fn an_opened_root_marks_a_directory_complete_only_once_discovery_listed_it() {
3923        let handle = crate::index::IndexHandle::new(Index::new("/root"));
3924        handle
3925            .transition_discovery(crate::index::DiscoveryTransition::Begin)
3926            .expect("begin discovery");
3927        handle
3928            .apply(&Observation::new(vec![
3929                upsert("known", EntryKind::Dir, attrs(0, 5)),
3930                upsert("pending", EntryKind::Dir, attrs(0, 5)),
3931            ]))
3932            .expect("seed directories");
3933        handle
3934            .apply_discovery(
3935                &Observation::new(Vec::new()),
3936                crate::index::DiscoveryCommit {
3937                    directory_complete: Some(PathBuf::from("known")),
3938                    transition: None,
3939                },
3940            )
3941            .expect("list one directory");
3942        let completeness = handle
3943            .read_with(|index| {
3944                files_of(&run(
3945                    index,
3946                    &flat(Selection { kinds: vec![EntryKind::Dir], ..Selection::default() }),
3947                ))
3948                .into_iter()
3949                .map(|row| (row.path.to_string_lossy().into_owned(), row.complete))
3950                .collect::<BTreeMap<_, _>>()
3951            })
3952            .expect("read");
3953        assert_eq!(
3954            completeness,
3955            BTreeMap::from([
3956                ("known".to_string(), Some(true)),
3957                ("pending".to_string(), Some(false))
3958            ])
3959        );
3960    }
3961
3962    #[test]
3963    fn a_filtered_tree_keeps_empty_matches_and_only_folds_visible_directories() {
3964        let mut index = sample();
3965        index
3966            .apply(&Observation::new(vec![upsert("src/empty", EntryKind::Dir, Attrs::default())]))
3967            .expect("apply");
3968        let selection = Selection {
3969            include: vec![pattern("empty")],
3970            depth: Some(Bound::All),
3971            min_share: Some(ShareThreshold::parse("0%").expect("valid share")),
3972            ..Selection::default()
3973        };
3974        let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
3975        assert_eq!(root.children.len(), 1);
3976        assert_eq!(root.children[0].name, "src");
3977        assert_eq!(root.children[0].children[0].name, "empty");
3978        let selection = Selection {
3979            include: vec![pattern("notes.txt")],
3980            depth: Some(Bound::Limit(0)),
3981            min_share: Some(ShareThreshold::parse("0%").expect("valid share")),
3982            ..Selection::default()
3983        };
3984        let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
3985        assert!(root.truncated, "a file leaf is hidden by depth zero");
3986        assert_eq!(root.bytes, 7);
3987    }
3988
3989    #[test]
3990    fn the_undocumented_docs_view_alias_is_rejected() {
3991        assert_eq!(
3992            ViewSpec::parse("documents").expect("the canonical view name parses"),
3993            ViewSpec::Documents
3994        );
3995        assert_eq!(
3996            ViewSpec::parse("docs").expect_err("an unreleased alias must not become a contract"),
3997            format!("expected one of {}", ViewSpec::vocabulary())
3998        );
3999    }
4000
4001    #[test]
4002    fn code_analysis_defaults_to_code_overview_and_keeps_document_projection() {
4003        assert_eq!(
4004            ViewSpec::resolve(None, AnalysisSet::NONE.with_code(), "view").expect("default").0,
4005            vec![ViewSpec::Code]
4006        );
4007        assert_eq!(
4008            ViewSpec::resolve(None, AnalysisSet::ALL, "view").expect("combined default").0,
4009            vec![ViewSpec::Code, ViewSpec::Documents]
4010        );
4011        assert_eq!(
4012            ViewSpec::resolve(Some("files"), AnalysisSet::ALL, "view").expect("explicit").0,
4013            vec![ViewSpec::Files]
4014        );
4015    }
4016
4017    /// The two directions between views and analyzers are one table read both ways: a view
4018    /// that implies an analyzer set is the view that set defaults to, so `--view code` and
4019    /// `--analyze code` cannot come to mean different reports.
4020    #[test]
4021    fn a_view_implies_exactly_the_analyzers_that_choose_it() {
4022        for view in ViewSpec::ALL {
4023            let implied = view.implies();
4024            match view {
4025                ViewSpec::Code | ViewSpec::Documents => {
4026                    assert!(implied.is_enabled(), "{view:?} has no metadata meaning");
4027                    assert_eq!(ViewSpec::default_for(implied), view, "{view:?}");
4028                }
4029                _ => assert_eq!(implied, AnalysisSet::NONE, "{view:?} has a metadata meaning"),
4030            }
4031        }
4032        assert_eq!(ViewSpec::Code.implies(), AnalysisSet::CODE_ONLY);
4033        assert_eq!(ViewSpec::Documents.implies(), AnalysisSet::WORDS_ONLY);
4034    }
4035
4036    /// The list grammar parses before any analyzer is known, and only named views imply:
4037    /// `full` is the metadata digest and the default is chosen by the analyzers.
4038    #[test]
4039    fn only_named_content_views_imply_analysis() {
4040        let implied = |spec| ViewList::parse(spec).expect("parses").implies();
4041        assert_eq!(implied(Some("code")), AnalysisSet::CODE_ONLY);
4042        assert_eq!(implied(Some("documents")), AnalysisSet::WORDS_ONLY);
4043        assert_eq!(implied(Some("tree,code,documents")), AnalysisSet::ALL);
4044        for spec in [None, Some("full"), Some("languages"), Some("types,families,summary")] {
4045            assert_eq!(implied(spec), AnalysisSet::NONE, "{spec:?}");
4046        }
4047        assert_eq!(
4048            ViewList::parse(Some("summary,documents,code")).expect("parses").implying(),
4049            [ViewSpec::Documents, ViewSpec::Code],
4050            "the caller's order"
4051        );
4052    }
4053
4054    /// `full` keeps a view exactly when the analyzers include what it implies, the test a
4055    /// read applies to a view its caller named.
4056    #[test]
4057    fn full_keeps_a_view_only_when_its_analyzers_ran() {
4058        let omitted = |content| ViewSpec::full_report(content).1;
4059        assert_eq!(omitted(AnalysisSet::NONE), [ViewSpec::Code, ViewSpec::Documents]);
4060        assert_eq!(omitted(AnalysisSet::LINES_ONLY), [ViewSpec::Code, ViewSpec::Documents]);
4061        assert_eq!(omitted(AnalysisSet::CODE_ONLY), [ViewSpec::Documents]);
4062        assert_eq!(omitted(AnalysisSet::WORDS_ONLY), [ViewSpec::Code]);
4063        assert!(omitted(AnalysisSet::ALL).is_empty());
4064    }
4065
4066    fn generated_at() -> std::time::SystemTime {
4067        UNIX_EPOCH + Duration::from_secs(1_001)
4068    }
4069
4070    fn run(index: &Index, query: &Query) -> Report {
4071        report(index, &crate::test_support::read_of(index, query.clone()), generated_at())
4072            .expect("the query is answerable over this index")
4073    }
4074
4075    fn query(views: &[ViewSpec], selection: Selection) -> Query {
4076        Query { selection, views: views.to_vec(), ..Query::default() }
4077    }
4078
4079    /// A flat List: the projection whose rows carry subtree metrics and completeness.
4080    fn flat(selection: Selection) -> Query {
4081        Query {
4082            selection,
4083            views: vec![ViewSpec::List],
4084            format: crate::report_format::Format::Paths,
4085            ..Query::default()
4086        }
4087    }
4088
4089    fn pattern(source: &str) -> Pattern {
4090        Pattern::parse(source).expect("pattern compiles")
4091    }
4092
4093    fn summary_of(report: &Report) -> SummaryRow {
4094        match report.sections.first().expect("a section") {
4095            Section::Summary(row) => *row,
4096            other => panic!("expected a summary, got {other:?}"),
4097        }
4098    }
4099
4100    fn files_of(report: &Report) -> Vec<FileRow> {
4101        match report.sections.first().expect("a section") {
4102            Section::Files { rows, .. } => rows.clone(),
4103            other => panic!("expected files, got {other:?}"),
4104        }
4105    }
4106
4107    fn types_of(report: &Report) -> Vec<TypeRow> {
4108        match report.sections.first().expect("a section") {
4109            Section::Extensions { rows, .. } => rows.clone(),
4110            other => panic!("expected types, got {other:?}"),
4111        }
4112    }
4113
4114    fn tree_of(report: &Report) -> TreeNode {
4115        match report.sections.first().expect("a section") {
4116            Section::Tree { root: Some(node), .. } => (**node).clone(),
4117            other => panic!("expected a tree, got {other:?}"),
4118        }
4119    }
4120
4121    #[test]
4122    fn an_unfiltered_summary_matches_the_precomputed_rollup() {
4123        let index = sample();
4124        let row = summary_of(&run(&index, &query(&[ViewSpec::Summary], Selection::default())));
4125        assert_eq!(row.files, 5);
4126        assert_eq!(row.dirs, 3);
4127        assert_eq!(row.bytes, 657);
4128        assert_eq!(row.newest_mtime_ns, Some(40));
4129    }
4130
4131    #[test]
4132    fn the_two_tiers_agree_on_the_same_question() {
4133        // The load-bearing property: reading pre-computed roll-ups and re-aggregating a
4134        // filtered walk must answer identically when the filter admits everything.
4135        let index = sample();
4136        let fast = summary_of(&run(&index, &query(&[ViewSpec::Summary], Selection::default())));
4137
4138        // A filter that excludes nothing still forces the traversal tier.
4139        let admits_everything = Selection { min_size: Some(0), ..Selection::default() };
4140        assert!(!admits_everything.is_unfiltered());
4141        let slow = summary_of(&run(&index, &query(&[ViewSpec::Summary], admits_everything)));
4142
4143        // `dirs` belongs in this comparison like every other tally. It used to be left
4144        // out because the two tiers genuinely disagreed: the traversal tier counted every
4145        // directory it descended into, so a filter admitting everything was the only
4146        // filter the two tiers could agree under.
4147        assert_eq!(
4148            (fast.files, fast.dirs, fast.bytes, fast.allocated, fast.newest_mtime_ns),
4149            (slow.files, slow.dirs, slow.bytes, slow.allocated, slow.newest_mtime_ns)
4150        );
4151    }
4152
4153    #[test]
4154    fn extension_rows_account_for_every_file_in_both_tiers() {
4155        // The rows are a partition of the tree, not a selection from it, so they have to
4156        // sum to what the summary reports. They did not: a name with no extension was
4157        // dropped from the roll-up rather than bucketed, so a 657-byte tree came back as
4158        // rows totalling less and nothing in the output said which files were missing.
4159        let mut index = sample();
4160        index
4161            .apply(&Observation::new(vec![
4162                upsert("Makefile", EntryKind::File, attrs(28, 50)),
4163                upsert(".gitignore", EntryKind::File, attrs(11, 51)),
4164            ]))
4165            .expect("apply");
4166
4167        // Both tiers: unfiltered reads the pre-computed roll-up, and any filter at all
4168        // forces the traversal to re-aggregate. They are separate code paths.
4169        for selection in [
4170            Selection::default(),
4171            Selection { min_size: Some(0), ..Selection::default() },
4172            Selection { kinds: vec![EntryKind::File], ..Selection::default() },
4173        ] {
4174            let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], selection.clone())));
4175            let summary = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection.clone())));
4176            assert_eq!(
4177                rows.iter().map(|row| row.bytes).sum::<u64>(),
4178                summary.bytes,
4179                "bytes unaccounted for under {selection:?}: {rows:?}"
4180            );
4181            assert_eq!(
4182                rows.iter().map(|row| row.files).sum::<u64>(),
4183                summary.files,
4184                "files unaccounted for under {selection:?}: {rows:?}"
4185            );
4186        }
4187    }
4188
4189    #[test]
4190    fn names_without_an_extension_share_one_bucket() {
4191        // `Makefile` and `.gitignore` have nothing in common as names, and inventing a
4192        // row per such name would turn the view into a file listing. One bucket keeps it
4193        // a roll-up while still accounting for the bytes.
4194        let mut index = sample();
4195        index
4196            .apply(&Observation::new(vec![
4197                upsert("Makefile", EntryKind::File, attrs(28, 50)),
4198                upsert(".gitignore", EntryKind::File, attrs(11, 51)),
4199            ]))
4200            .expect("apply");
4201
4202        let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], Selection::default())));
4203        let bucket = rows
4204            .iter()
4205            .find(|row| row.extension == crate::classify::NO_EXTENSION)
4206            .expect("a bucket for the extension-less names");
4207        assert_eq!(bucket.files, 2);
4208        assert_eq!(bucket.bytes, 39);
4209        // And it never swallows a name that does have one.
4210        assert!(rows.iter().any(|row| row.extension == ".rs"), "{rows:?}");
4211    }
4212
4213    #[test]
4214    fn a_summary_counts_the_union_of_listed_entries_and_directory_contents() {
4215        // One query must not give two answers. The directory tally is folded from the
4216        // walk while the files view is filtered entry by entry, so they are two paths to
4217        // the same number and drifted apart: `--kind file` answered "5 files, 3
4218        // directories" while the files view under the same selection listed no directory
4219        // at all.
4220        let index = sample();
4221        for selection in [
4222            Selection { kinds: vec![EntryKind::File], ..Selection::default() },
4223            Selection { kinds: vec![EntryKind::Dir], ..Selection::default() },
4224            Selection { include: vec![pattern("*.rs")], ..Selection::default() },
4225            Selection { exclude: vec![pattern("docs")], ..Selection::default() },
4226            Selection { min_size: Some(1_000_000), ..Selection::default() },
4227            Selection { min_size: Some(0), ..Selection::default() },
4228        ] {
4229            let summary = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection.clone())));
4230            let listed = files_of(&run(&index, &query(&[ViewSpec::Files], selection.clone())));
4231            let dirs = listed.iter().filter(|row| row.kind == EntryKind::Dir).count() as u64;
4232            let files = every_entry(&index)
4233                .iter()
4234                .filter(|entry| {
4235                    entry.kind == EntryKind::File
4236                        && listed.iter().any(|row| {
4237                            entry.path == row.path
4238                                || (row.kind == EntryKind::Dir && entry.path.starts_with(&row.path))
4239                        })
4240                })
4241                .count() as u64;
4242            assert_eq!(summary.dirs, dirs, "directory counts disagree under {selection:?}");
4243            assert_eq!(summary.files, files, "file counts disagree under {selection:?}");
4244        }
4245    }
4246
4247    #[test]
4248    fn a_rejected_directory_is_still_descended_into() {
4249        // Filtering a directory out of the tally must not filter out what is under it:
4250        // `--kind file` reports no directories and every file, at every depth.
4251        let index = sample();
4252        let selection = Selection { kinds: vec![EntryKind::File], ..Selection::default() };
4253        let row = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection)));
4254        assert_eq!(row.dirs, 0, "no directory was admitted");
4255        assert_eq!(row.files, 5, "including src/deep/nested.rs, two levels down");
4256        assert_eq!(row.bytes, 657, "and its bytes");
4257    }
4258
4259    #[test]
4260    fn nested_directory_counts_roll_up_through_every_level() {
4261        // The tally is taken in the pre-order pass and folded in the post-order one, so a
4262        // directory admitted three levels down has to reach the root through both.
4263        let index = sample();
4264        let selection = Selection { kinds: vec![EntryKind::Dir], ..Selection::default() };
4265        let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4266        assert_eq!(root.dirs, 3, "src, src/deep, and docs");
4267        assert_eq!(root.files, 4, "matching directories cover their regular files");
4268        let src = root.children.iter().find(|node| node.name == "src").expect("src");
4269        assert_eq!(src.dirs, 1, "src/deep, counted for src as well as for the root");
4270    }
4271
4272    #[test]
4273    fn selection_narrows_a_summary_to_what_it_admits() {
4274        let index = sample();
4275        let selection = Selection { include: vec![pattern("*.rs")], ..Selection::default() };
4276        let row = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection)));
4277        assert_eq!(row.files, 3, "three .rs files");
4278        assert_eq!(row.bytes, 350);
4279    }
4280
4281    #[test]
4282    fn a_files_view_lists_matching_entries_in_name_order_by_default() {
4283        let index = sample();
4284        let selection = Selection { include: vec![pattern("*.rs")], ..Selection::default() };
4285        let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
4286        // Built from components so the expectation carries the native separator: a
4287        // literal "src/main.rs" passes on Unix and fails on Windows for a reason that
4288        // has nothing to do with the view under test.
4289        let paths: Vec<PathBuf> = rows.iter().map(|row| row.path.clone()).collect();
4290        let expected: Vec<PathBuf> = [["src", "deep", "nested.rs"].iter().collect::<PathBuf>()]
4291            .into_iter()
4292            .chain([["src", "lib.rs"].iter().collect::<PathBuf>()])
4293            .chain([["src", "main.rs"].iter().collect::<PathBuf>()])
4294            .collect();
4295        assert_eq!(paths, expected);
4296    }
4297
4298    #[test]
4299    fn sorting_and_limiting_compose_without_a_dedicated_view() {
4300        // "Largest files" is not a view; it is files plus sort plus limit.
4301        let index = sample();
4302        // Apparent, because the sample's allocated sizes round to 512-byte blocks and tie.
4303        let selection = Selection {
4304            kinds: vec![EntryKind::File],
4305            sort: Some(SortKey::Size),
4306            limit: Some(Bound::Limit(2)),
4307            size: SizeMetric::Apparent,
4308            ..Selection::default()
4309        };
4310        let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
4311        assert_eq!(rows.len(), 2);
4312        assert_eq!(rows[0].bytes, 300, "largest first");
4313        assert_eq!(rows[1].bytes, 200);
4314    }
4315
4316    #[test]
4317    fn reverse_flips_whatever_order_is_in_effect() {
4318        let index = sample();
4319        let selection = Selection {
4320            kinds: vec![EntryKind::File],
4321            sort: Some(SortKey::Size),
4322            reverse: true,
4323            size: SizeMetric::Apparent,
4324            ..Selection::default()
4325        };
4326        let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
4327        assert_eq!(rows[0].bytes, 7, "smallest first once reversed");
4328    }
4329
4330    #[test]
4331    fn a_modified_window_selects_by_time() {
4332        let index = sample();
4333        let selection = Selection {
4334            kinds: vec![EntryKind::File],
4335            modified: ModifiedWindow { since: Some(20), before: Some(40) },
4336            sort: Some(SortKey::Mtime),
4337            ..Selection::default()
4338        };
4339        let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
4340        let mut times: Vec<i64> = rows.iter().map(|row| row.mtime_ns).collect();
4341        times.sort_unstable();
4342        assert_eq!(times, vec![20, 30], "inclusive start, exclusive end");
4343    }
4344
4345    #[test]
4346    fn a_types_view_reports_both_size_metrics_per_extension() {
4347        let index = sample();
4348        let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], Selection::default())));
4349        let rs = rows.iter().find(|row| row.extension == ".rs").expect(".rs present");
4350        assert_eq!((rs.files, rs.bytes), (3, 350));
4351        assert_eq!(rs.allocated, 1536, "three files, one 512-byte block each");
4352        // Size-ranked by default: .rs (350) then .md (300) then .txt (7).
4353        let order: Vec<&str> = rows.iter().map(|row| row.extension.as_str()).collect();
4354        assert_eq!(order, vec![".rs", ".md", ".txt"]);
4355    }
4356
4357    #[test]
4358    fn a_tree_view_reports_directories_with_their_subtree_totals() {
4359        let index = sample();
4360        let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], Selection::default())));
4361        assert_eq!(tree.name, ".");
4362        assert_eq!(tree.bytes, 657);
4363        // Size-ranked children: src (350) before docs (300).
4364        let names: Vec<&str> = tree.children.iter().map(|child| child.name.as_str()).collect();
4365        assert_eq!(names, vec!["src", "docs", "notes.txt"]);
4366        let src = &tree.children[0];
4367        assert_eq!(src.bytes, 350);
4368        let nested: Vec<&str> = src.children.iter().map(|child| child.name.as_str()).collect();
4369        assert!(nested.contains(&"deep"));
4370    }
4371
4372    #[test]
4373    fn depth_zero_keeps_dus_meaning_of_root_totals_only() {
4374        let index = sample();
4375        let selection = Selection { depth: Some(Bound::Limit(0)), ..Selection::default() };
4376        let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4377        assert_eq!(tree.bytes, 657, "totals still cover the whole tree");
4378        assert!(tree.children.is_empty(), "but nothing below the root is listed");
4379        assert!(tree.truncated, "and the report says so rather than implying emptiness");
4380    }
4381
4382    /// A view `full` had to drop is named on the report, so every surface says so.
4383    ///
4384    /// This lived in the CLI, which meant a caller reaching the same wall through the
4385    /// library got a report quietly missing a section and no way to learn why (fdu-x8u6).
4386    #[test]
4387    fn a_dropped_view_is_named_on_the_report_rather_than_by_one_surface() {
4388        let (selected, omitted) = ViewSpec::resolve(Some("full"), AnalysisSet::NONE, "view")
4389            .expect("full resolves without analyzers");
4390        assert!(omitted.contains(&ViewSpec::Code));
4391        assert!(omitted.contains(&ViewSpec::Documents));
4392
4393        let query = Query { views: selected, omitted_views: omitted, ..Query::default() };
4394        let (notes, tips) = display_notes(&query, AnalysisSet::NONE, &ControlCoverage::NotObserved);
4395        assert_eq!(notes, ["note: full omits code, documents without analysis"]);
4396        assert_eq!(tips, ["tip: include them: analyze all"]);
4397
4398        // Nothing dropped, nothing said.
4399        let (selected, omitted) = ViewSpec::resolve(Some("full"), AnalysisSet::ALL, "view")
4400            .expect("full resolves with analyzers");
4401        assert!(omitted.is_empty(), "every view is answerable with analysis enabled");
4402        let query = Query { views: selected, omitted_views: omitted, ..Query::default() };
4403        assert!(
4404            display_notes(&query, AnalysisSet::ALL, &ControlCoverage::NotObserved).0.is_empty()
4405        );
4406    }
4407
4408    /// `full` implies nothing, so under one analyzer it drops the view that needs the
4409    /// other. The note names the analyzer that is missing, and the tip names the one value
4410    /// that keeps what ran and adds what is missing -- naming only the missing analyzer
4411    /// would be a command that loses the one already chosen.
4412    #[test]
4413    fn full_names_the_missing_analyzer_and_a_value_that_keeps_the_present_one() {
4414        for (content, omitted, note) in [
4415            (
4416                AnalysisSet::LINES_ONLY,
4417                "code, documents",
4418                "note: full omits code, documents without code and words analysis",
4419            ),
4420            (
4421                AnalysisSet::CODE_ONLY,
4422                "documents",
4423                "note: full omits documents without words analysis",
4424            ),
4425            (AnalysisSet::WORDS_ONLY, "code", "note: full omits code without code analysis"),
4426        ] {
4427            let (selected, dropped) =
4428                ViewSpec::resolve(Some("full"), content, "view").expect("full resolves");
4429            let labels: Vec<&str> = dropped.iter().map(|view| view.label()).collect();
4430            assert_eq!(labels.join(", "), omitted, "{content:?}");
4431            let query = Query {
4432                views: selected,
4433                omitted_views: dropped,
4434                axes: &AxisNames::FLAGS,
4435                ..Query::default()
4436            };
4437            let (notes, tips) = display_notes(&query, content, &ControlCoverage::NotObserved);
4438            assert_eq!(notes, [note], "{content:?}");
4439            assert_eq!(tips, ["tip: include them: --analyze all"], "{content:?}");
4440        }
4441    }
4442
4443    /// Analysis no selected view displays is named with the views where it would show:
4444    /// the default for the analyzers, which is the view each implies.
4445    #[test]
4446    fn analysis_no_view_shows_names_the_view_that_would_show_it() {
4447        for (content, views, note, tip) in [
4448            (
4449                AnalysisSet::CODE_ONLY,
4450                vec![ViewSpec::Summary],
4451                "note: code analysis not shown by summary",
4452                "tip: show it: --view code",
4453            ),
4454            (
4455                AnalysisSet::ALL,
4456                vec![ViewSpec::Tree],
4457                "note: code and words analysis not shown by tree",
4458                "tip: show it: --view code,documents",
4459            ),
4460            (
4461                AnalysisSet::LINES_ONLY,
4462                vec![ViewSpec::Summary, ViewSpec::Files],
4463                "note: lines analysis not shown by summary, files",
4464                "tip: show it: --view families",
4465            ),
4466            (
4467                AnalysisSet::WORDS_ONLY,
4468                vec![ViewSpec::List],
4469                "note: words analysis not shown by list",
4470                "tip: show it: --view documents",
4471            ),
4472        ] {
4473            let query = Query { views, axes: &AxisNames::FLAGS, ..Query::default() };
4474            let (notes, tips) = display_notes(&query, content, &ControlCoverage::NotObserved);
4475            assert_eq!((notes, tips), (vec![note.to_owned()], vec![tip.to_owned()]));
4476        }
4477
4478        // A view that displays analysis, or a ranking by one of its metrics, says nothing.
4479        let shown = Query { views: vec![ViewSpec::Languages], ..Query::default() };
4480        let (notes, tips) =
4481            display_notes(&shown, AnalysisSet::CODE_ONLY, &ControlCoverage::NotObserved);
4482        assert!(notes.is_empty() && tips.is_empty(), "{notes:?} {tips:?}");
4483        let ranked = Query {
4484            views: vec![ViewSpec::Files],
4485            selection: Selection {
4486                sort: Some(SortKey::Metric("code_lines")),
4487                ..Selection::default()
4488            },
4489            ..Query::default()
4490        };
4491        let (notes, _) =
4492            display_notes(&ranked, AnalysisSet::CODE_ONLY, &ControlCoverage::NotObserved);
4493        assert!(notes.is_empty(), "{notes:?}");
4494    }
4495
4496    /// Analysis some selected view displays and some does not is named analyzer by
4497    /// analyzer, and the tip keeps the caller's views, adding the ones that show the rest.
4498    #[test]
4499    fn analysis_partly_shown_names_what_no_view_displays() {
4500        for (content, views, sort, note, tip) in [
4501            (
4502                AnalysisSet::ALL,
4503                vec![ViewSpec::Documents],
4504                None,
4505                "note: code analysis not shown by documents",
4506                "tip: show it: --view documents,code",
4507            ),
4508            (
4509                AnalysisSet::ALL,
4510                vec![ViewSpec::Summary, ViewSpec::Code],
4511                None,
4512                "note: words analysis not shown by summary, code",
4513                "tip: show it: --view summary,code,documents",
4514            ),
4515            // A ranking displays its own analyzer and nothing else.
4516            (
4517                AnalysisSet::ALL,
4518                vec![ViewSpec::Files],
4519                Some(SortKey::Metric("code_lines")),
4520                "note: words analysis not shown by files",
4521                "tip: show it: --view files,documents",
4522            ),
4523        ] {
4524            let query = Query {
4525                views,
4526                selection: Selection { sort, ..Selection::default() },
4527                axes: &AxisNames::FLAGS,
4528                ..Query::default()
4529            };
4530            let (notes, tips) = display_notes(&query, content, &ControlCoverage::NotObserved);
4531            assert_eq!((notes, tips), (vec![note.to_owned()], vec![tip.to_owned()]));
4532        }
4533
4534        // The grouping views show every analyzer, so beside one nothing goes unremarked.
4535        for view in [ViewSpec::Types, ViewSpec::Families, ViewSpec::Languages] {
4536            let query = Query { views: vec![view], ..Query::default() };
4537            let (notes, tips) =
4538                display_notes(&query, AnalysisSet::ALL, &ControlCoverage::NotObserved);
4539            assert!(notes.is_empty() && tips.is_empty(), "{view:?}: {notes:?} {tips:?}");
4540        }
4541    }
4542
4543    /// The display table agrees with the implication table and the defaults: each view
4544    /// shows what it implies, and the default views for any analyzer set show all of it,
4545    /// so a request that names no view is never told its analysis went unshown.
4546    #[test]
4547    fn every_view_shows_what_it_implies_and_the_defaults_show_everything() {
4548        for view in ViewSpec::ALL {
4549            assert!(view.shows().contains(view.implies()), "{view:?}");
4550        }
4551        for content in [
4552            AnalysisSet::LINES_ONLY,
4553            AnalysisSet::CODE_ONLY,
4554            AnalysisSet::WORDS_ONLY,
4555            AnalysisSet::ALL,
4556        ] {
4557            let shown = ViewSpec::defaults_for(content)
4558                .into_iter()
4559                .fold(AnalysisSet::NONE, |set, view| set.union(view.shows()));
4560            assert!(shown.contains(content), "{content:?}");
4561            let query = Query { views: ViewSpec::defaults_for(content), ..Query::default() };
4562            let (notes, _) = display_notes(&query, content, &ControlCoverage::NotObserved);
4563            assert!(notes.is_empty(), "{content:?}: {notes:?}");
4564        }
4565    }
4566
4567    /// A rule belongs to the library; the words a caller can act on belong to their
4568    /// surface. Both diagnostics name two axes, and naming them with flags told a Python
4569    /// caller to add an `--analyze` their surface does not have (fdu-4apt).
4570    ///
4571    /// Asserted as "names mine, never the other's" rather than by quoting either sentence,
4572    /// so rewording the rule cannot break this and changing the vocabulary cannot pass it.
4573    /// The refused-controls note names a few directories and counts the rest, breaks the
4574    /// reasons down only when every refusal is listed, and raises exactly the limits that
4575    /// fired, each by its own knob.
4576    #[test]
4577    fn the_refused_controls_note_bounds_its_list_and_matches_its_remedy_to_the_reasons() {
4578        use crate::control::{
4579            ControlLimits, ControlObservation, ControlRefusalReason, RefusedControl,
4580        };
4581        let defaults = ControlLimits::default();
4582        for (reason, wanted, unwanted) in [
4583            (ControlRefusalReason::Budget, "--gitignore-budget", "--gitignore-line-limit"),
4584            (ControlRefusalReason::LineLimit, "--gitignore-line-limit", "--gitignore-budget"),
4585        ] {
4586            let coverage = ControlCoverage::Observed(ControlObservation {
4587                limits: defaults,
4588                applied: 7,
4589                rules: 0,
4590                refused: 1,
4591                refusals: vec![RefusedControl {
4592                    path: Path::new("vendor").join(".gitignore"),
4593                    reason,
4594                }],
4595            });
4596            let (note, tip) = refused_controls_note(&coverage, &AxisNames::FLAGS).expect("refusal");
4597            assert!(
4598                note.starts_with(
4599                    "note: ignore classification incomplete: 1 ignore file not applied"
4600                )
4601            );
4602            assert!(note.contains("vendor"));
4603            assert!(!note.contains("--"), "facts do not repeat flag advice");
4604            let tip = tip.expect("bounded refusal has a remedy");
4605            assert!(tip.starts_with("tip:"));
4606            assert!(tip.contains(wanted));
4607            assert!(!tip.contains(unwanted));
4608        }
4609        let coverage = |limits, refused| {
4610            ControlCoverage::Observed(ControlObservation {
4611                limits,
4612                applied: 7,
4613                rules: 0,
4614                refused,
4615                refusals: (0..crate::MAX_RETAINED_ISSUES)
4616                    .map(|i| RefusedControl {
4617                        path: Path::new(&format!("d{i:02}")).join(".gitignore"),
4618                        reason: ControlRefusalReason::Budget,
4619                    })
4620                    .collect(),
4621            })
4622        };
4623        let (note, tip) =
4624            refused_controls_note(&coverage(defaults, 1000), &AxisNames::FLAGS).expect("truncated");
4625        assert!(note.contains("995 more"));
4626        assert!(!note.contains("d05"));
4627        let tip = tip.expect("both bounded limits may explain unlisted refusals");
4628        assert!(tip.contains("--gitignore-budget") && tip.contains("--gitignore-line-limit"));
4629        let (_, tip) = refused_controls_note(
4630            &coverage(ControlLimits { line_limit: None, ..defaults }, 1000),
4631            &AxisNames::FLAGS,
4632        )
4633        .expect("truncated");
4634        assert!(!tip.expect("budget remedy").contains("--gitignore-line-limit"));
4635        let (_, tip) = refused_controls_note(
4636            &coverage(ControlLimits { budget: None, line_limit: None }, 64),
4637            &AxisNames::FLAGS,
4638        )
4639        .expect("retained refusal");
4640        assert!(tip.is_none(), "do not suggest raising unbounded limits");
4641        assert!(refused_controls_note(&ControlCoverage::NotObserved, &AxisNames::FLAGS).is_none());
4642    }
4643
4644    #[test]
4645    fn a_diagnostic_names_the_axes_the_requesting_surface_uses() {
4646        let (selected, omitted) = ViewSpec::resolve(Some("full"), AnalysisSet::NONE, "view")
4647            .expect("full resolves without analyzers");
4648
4649        for (axes, mine, theirs) in [
4650            (&AxisNames::FLAGS, "--analyze", "analyze"),
4651            (&AxisNames::FIELDS, "analyze", "--analyze"),
4652        ] {
4653            let query = Query {
4654                views: selected.clone(),
4655                omitted_views: omitted.clone(),
4656                axes,
4657                ..Query::default()
4658            };
4659            // Anchored on the whole phrase, because `--analyze` contains `analyze`: a bare
4660            // `contains` for the other surface's spelling matches its own. That is the same
4661            // tokenisation trap the watch-scope substitution had to avoid.
4662            let tip =
4663                display_notes(&query, AnalysisSet::NONE, &ControlCoverage::NotObserved).1.remove(0);
4664            assert!(tip.contains(&format!(": {mine} ")), "{tip} must name {mine}");
4665            assert!(!tip.contains(&format!(": {theirs} ")), "{tip} must not name {theirs}");
4666        }
4667
4668        // The same for the hard error, which names the view axis as well. The rule is the
4669        // request model's; what a surface reads is this rendering of it.
4670        for (axes, view, analyze) in
4671            [(&AxisNames::FLAGS, "--view", "--analyze"), (&AxisNames::FIELDS, "view", "analyze")]
4672        {
4673            let error = crate::query::RequestError::ViewNeedsAnalyzer {
4674                view: ViewSpec::Documents,
4675                held: AnalysisSet::NONE,
4676                holder: crate::query::BasisHolder::Index,
4677            }
4678            .message(axes);
4679            assert!(error.starts_with(&format!("{view} documents")), "{error}");
4680            assert!(error.contains(&format!("with {analyze} ")), "{error}");
4681            let theirs = if analyze == "--analyze" { "analyze" } else { "--analyze" };
4682            assert!(!error.contains(&format!("with {theirs} ")), "{error}");
4683        }
4684    }
4685
4686    /// A library caller who never says otherwise is not the command line, so the default
4687    /// vocabulary is the one their surface uses.
4688    #[test]
4689    fn the_default_vocabulary_is_the_librarys_own() {
4690        assert_eq!(*Query::default().axes, AxisNames::FIELDS);
4691    }
4692
4693    #[test]
4694    fn a_depth_bound_marks_hidden_file_and_directory_rows_as_truncated() {
4695        let index = sample();
4696        let selection = Selection { depth: Some(Bound::Limit(1)), ..Selection::default() };
4697        let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4698
4699        let src = tree.children.iter().find(|child| child.name == "src").expect("src");
4700        assert!(src.truncated, "src with a hidden directory child is truncated");
4701
4702        let docs = tree.children.iter().find(|child| child.name == "docs").expect("docs");
4703        assert!(docs.children.is_empty());
4704        assert!(docs.truncated, "significant file leaves are rows beyond the depth boundary");
4705    }
4706
4707    #[test]
4708    fn a_tree_limit_caps_section_rows_including_the_root() {
4709        let index = sample();
4710        let selection = Selection { limit: Some(Bound::Limit(1)), ..Selection::default() };
4711        let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4712        assert_eq!(tree.children.len(), 0);
4713        assert!(tree.truncated);
4714        assert!(tree.omissions.iter().any(|omission| omission.reason == TreeOmissionReason::Rows));
4715    }
4716
4717    #[test]
4718    fn tree_share_is_root_relative_inclusive_and_does_not_hide_eleventh_sibling() {
4719        let mut index = Index::new("/root");
4720        let mut ops = Vec::new();
4721        for number in 0..11 {
4722            let directory = format!("d{number:02}");
4723            ops.push(upsert(&directory, EntryKind::Dir, Attrs::default()));
4724            ops.push(upsert(&format!("{directory}/file"), EntryKind::File, attrs(20, 1)));
4725        }
4726        ops.push(upsert("filler", EntryKind::File, attrs(780, 1)));
4727        index.apply(&Observation::new(ops)).expect("apply");
4728        let selection = Selection { size: SizeMetric::Apparent, ..Selection::default() };
4729        let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4730        assert_eq!(root.children.len(), 12, "eleven 2% directories plus the large file");
4731        assert!(root.children.iter().any(|child| child.name == "d10"));
4732        assert!(root.omissions.is_empty());
4733
4734        let selection = Selection {
4735            size: SizeMetric::Apparent,
4736            breadth: Some(Bound::Limit(10)),
4737            ..Selection::default()
4738        };
4739        let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4740        assert_eq!(root.children.len(), 10);
4741        assert_eq!(root.omissions[0].reason, TreeOmissionReason::Breadth);
4742        assert_eq!(root.omissions[0].entries, 2);
4743        assert_eq!(root.omissions[0].bytes, Some(40));
4744    }
4745
4746    #[test]
4747    fn tree_threshold_keeps_exact_one_percent_file_leaf() {
4748        let mut index = Index::new("/root");
4749        index
4750            .apply(&Observation::new(vec![
4751                upsert("one-percent", EntryKind::File, attrs(10, 1)),
4752                upsert("below", EntryKind::File, attrs(9, 1)),
4753                upsert("rest", EntryKind::File, attrs(981, 1)),
4754            ]))
4755            .expect("apply");
4756        let root = tree_of(&run(
4757            &index,
4758            &query(
4759                &[ViewSpec::Tree],
4760                Selection { size: SizeMetric::Apparent, ..Selection::default() },
4761            ),
4762        ));
4763        assert_eq!(root.children.len(), 2);
4764        assert!(
4765            root.children
4766                .iter()
4767                .any(|child| child.name == "one-percent" && child.kind == EntryKind::File)
4768        );
4769        assert_eq!(root.omissions[0].reason, TreeOmissionReason::Share);
4770        assert_eq!(root.omissions[0].bytes, Some(9));
4771    }
4772
4773    /// Each display boundary hides a disjoint subtree. The breadth omission covers
4774    /// two files under one directory, so omitted child roots cannot stand in for files.
4775    fn mixed_remainder_index() -> Index {
4776        let mut index = Index::new("/root");
4777        index.apply_ok(&Observation::new(vec![
4778            upsert("A", EntryKind::Dir, attrs(0, 0)),
4779            upsert("A/a1", EntryKind::Dir, attrs(0, 0)),
4780            upsert("A/a1/x", EntryKind::Dir, attrs(0, 0)),
4781            upsert("A/a1/x/leaf", EntryKind::File, attrs(1960, 0)),
4782            upsert("A/a1/tiny", EntryKind::File, attrs(40, 0)),
4783            upsert("A/a2", EntryKind::Dir, attrs(0, 0)),
4784            upsert("A/a2/file", EntryKind::File, attrs(2000, 0)),
4785            upsert("B", EntryKind::Dir, attrs(0, 0)),
4786            upsert("B/b1", EntryKind::Dir, attrs(0, 0)),
4787            upsert("B/b1/file", EntryKind::File, attrs(2000, 0)),
4788            upsert("B/b2", EntryKind::Dir, attrs(0, 0)),
4789            upsert("B/b2/file", EntryKind::File, attrs(2000, 0)),
4790            upsert("C", EntryKind::Dir, attrs(0, 0)),
4791            upsert("C/c1", EntryKind::Dir, attrs(0, 0)),
4792            upsert("C/c1/first", EntryKind::File, attrs(1000, 0)),
4793            upsert("C/c1/second", EntryKind::File, attrs(1000, 0)),
4794        ]));
4795        index
4796    }
4797
4798    #[test]
4799    fn nested_mixed_omissions_have_one_exact_remainder_golden() {
4800        let index = mixed_remainder_index();
4801        let selection = Selection {
4802            size: SizeMetric::Apparent,
4803            depth: Some(Bound::Limit(3)),
4804            breadth: Some(Bound::Limit(2)),
4805            limit: Some(Bound::Limit(8)),
4806            min_share: Some(ShareThreshold::parse("0.5%").expect("share")),
4807            ..Selection::default()
4808        };
4809        let report = run(&index, &query(&[ViewSpec::Tree], selection));
4810        let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
4811            panic!("expected tree")
4812        };
4813        let remainder = TreeRemainder::from_tree(Some(root), omissions).expect("hidden content");
4814        assert_eq!((root.files, root.bytes, root.allocated), (7, 10_000, 10_752));
4815        assert_eq!(
4816            (remainder.files, remainder.bytes, remainder.allocated),
4817            (Some(2), Some(2_000), Some(2_048))
4818        );
4819        assert_eq!(remainder.reasons, vec![TreeOmissionReason::Breadth]);
4820        assert_eq!(
4821            crate::report_format::render(&report, crate::report_format::Format::Text, false)
4822                .expect("render"),
4823            include_str!("../../tests/golden/remainder-tree.txt"),
4824        );
4825    }
4826
4827    #[test]
4828    fn displayed_directories_represent_their_descendants_even_when_expansion_stops() {
4829        let index = mixed_remainder_index();
4830        let selection = Selection {
4831            size: SizeMetric::Apparent,
4832            depth: Some(Bound::Limit(1)),
4833            breadth: Some(Bound::All),
4834            limit: Some(Bound::All),
4835            min_share: Some(ShareThreshold::parse("0%").expect("share")),
4836            ..Selection::default()
4837        };
4838        let report = run(&index, &query(&[ViewSpec::Tree], selection));
4839        let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
4840            panic!("expected tree")
4841        };
4842        assert_eq!((root.files, root.bytes), (7, 10_000));
4843        assert_eq!(root.children.len(), 3);
4844        assert!(root.children.iter().all(|child| child.kind == EntryKind::Dir));
4845        assert!(root.children.iter().any(|child| !child.omissions.is_empty()));
4846        assert!(TreeRemainder::from_tree(Some(root), omissions).is_none());
4847        let diagnostics = crate::report_format::diagnostic_lines(&report);
4848        assert!(!diagnostics.notes.iter().any(|note| note.contains("… and more")));
4849        assert!(diagnostics.notes.iter().any(|note| note.contains("depth 1")));
4850        assert!(diagnostics.tips.contains(&format!("tip: show more: {}=all", report.axes.depth)));
4851
4852        let depth_zero = run(
4853            &index,
4854            &query(
4855                &[ViewSpec::Tree],
4856                Selection {
4857                    size: SizeMetric::Apparent,
4858                    depth: Some(Bound::Limit(0)),
4859                    min_share: Some(ShareThreshold::parse("0%").expect("share")),
4860                    ..Selection::default()
4861                },
4862            ),
4863        );
4864        let Section::Tree { root: Some(root), omissions, .. } = &depth_zero.sections[0] else {
4865            panic!("expected tree")
4866        };
4867        let remainder = TreeRemainder::from_tree(Some(root), omissions).expect("root alone");
4868        assert_eq!((remainder.files, remainder.bytes), (Some(7), Some(10_000)));
4869    }
4870
4871    #[test]
4872    fn selected_leaf_ledger_conserves_every_top_level_tree_partition() {
4873        // This ledger is independent of the index rollups and omission records. A listed
4874        // direct child represents all selected leaves below it, whether or not its own
4875        // children were expanded. The root is context, not a second represented set.
4876        let files = [
4877            ("src/main.rs", 100, 512, false),
4878            ("src/lib.rs", 200, 512, false),
4879            ("src/debug.log", 25, 512, true),
4880            ("docs/guide.md", 300, 512, false),
4881            ("build/cache/out.bin", 1_000, 1_024, true),
4882        ];
4883        let dirs = [("src", false), ("docs", false), ("build", true), ("build/cache", true)];
4884        let index = classified_sample();
4885        let within = |path: &str, parent: &Path| {
4886            parent.as_os_str().is_empty() || Path::new(path).starts_with(parent)
4887        };
4888        for (population, excluded) in [
4889            (IgnoredEntries::Include, None),
4890            (IgnoredEntries::Exclude, None),
4891            (IgnoredEntries::Only, None),
4892            (IgnoredEntries::Include, Some("build")),
4893            (IgnoredEntries::Include, Some("*.rs")),
4894        ] {
4895            let admitted = |path: &str, ignored: bool| {
4896                population.admits(ignored)
4897                    && !matches!(excluded, Some("build") if Path::new(path).starts_with("build"))
4898                    && !matches!(excluded, Some("*.rs") if Path::new(path)
4899                        .extension()
4900                        .is_some_and(|extension| extension.eq_ignore_ascii_case("rs")))
4901            };
4902            let selected_files: Vec<_> = files
4903                .iter()
4904                .copied()
4905                .filter(|(path, _, _, ignored)| admitted(path, *ignored))
4906                .collect();
4907            let selected_dirs: Vec<_> =
4908                dirs.iter().copied().filter(|(path, ignored)| admitted(path, *ignored)).collect();
4909            for size in [SizeMetric::Apparent, SizeMetric::Allocated] {
4910                for share in ["0%", "1%", "50%", "100%"] {
4911                    for depth in [Bound::Limit(0), Bound::Limit(1), Bound::All] {
4912                        for breadth in
4913                            [Bound::Limit(0), Bound::Limit(1), Bound::Limit(2), Bound::All]
4914                        {
4915                            for limit in
4916                                [Bound::Limit(0), Bound::Limit(1), Bound::Limit(3), Bound::All]
4917                            {
4918                                let selection = Selection {
4919                                    ignored: population,
4920                                    exclude: excluded
4921                                        .map_or_else(Vec::new, |name| vec![pattern(name)]),
4922                                    size,
4923                                    min_share: Some(ShareThreshold::parse(share).expect("share")),
4924                                    depth: Some(depth),
4925                                    breadth: Some(breadth),
4926                                    limit: Some(limit),
4927                                    ..Selection::default()
4928                                };
4929                                let report = run(
4930                                    &index,
4931                                    &query(&[ViewSpec::Summary, ViewSpec::Tree], selection.clone()),
4932                                );
4933                                let Section::Summary(summary) = &report.sections[0] else {
4934                                    panic!("summary")
4935                                };
4936                                let Section::Tree { root, omissions, .. } = &report.sections[1]
4937                                else {
4938                                    panic!("tree")
4939                                };
4940                                let expected = (
4941                                    selected_files.len() as u64,
4942                                    selected_dirs.len() as u64,
4943                                    selected_files.iter().map(|file| file.1).sum::<u64>(),
4944                                    selected_files.iter().map(|file| file.2).sum::<u64>(),
4945                                );
4946                                let context = format!("{selection:?}");
4947                                assert_eq!(
4948                                    (summary.files, summary.dirs, summary.bytes, summary.allocated),
4949                                    expected,
4950                                    "summary: {context}"
4951                                );
4952                                if let Some(root) = root {
4953                                    assert_eq!(
4954                                        (root.files, root.dirs, root.bytes, root.allocated),
4955                                        expected,
4956                                        "root: {context}"
4957                                    );
4958                                    let mut stack = vec![root.as_ref()];
4959                                    while let Some(node) = stack.pop() {
4960                                        let contained: Vec<_> = selected_files
4961                                            .iter()
4962                                            .filter(|file| within(file.0, &node.path))
4963                                            .collect();
4964                                        let contained_dirs = selected_dirs
4965                                            .iter()
4966                                            .filter(|dir| {
4967                                                within(dir.0, &node.path)
4968                                                    && Path::new(dir.0) != node.path
4969                                            })
4970                                            .count()
4971                                            as u64;
4972                                        let ignored: Vec<_> =
4973                                            contained.iter().filter(|file| file.3).collect();
4974                                        let ignored_dirs = selected_dirs
4975                                            .iter()
4976                                            .filter(|dir| {
4977                                                dir.1
4978                                                    && within(dir.0, &node.path)
4979                                                    && Path::new(dir.0) != node.path
4980                                            })
4981                                            .count()
4982                                            as u64;
4983                                        assert_eq!(
4984                                            (node.files, node.dirs, node.bytes, node.allocated),
4985                                            (
4986                                                contained.len() as u64,
4987                                                contained_dirs,
4988                                                contained.iter().map(|file| file.1).sum::<u64>(),
4989                                                contained.iter().map(|file| file.2).sum::<u64>(),
4990                                            ),
4991                                            "node {:?}: {context}",
4992                                            node.path
4993                                        );
4994                                        assert_eq!(
4995                                            node.ignored.map(|part| (
4996                                                part.files,
4997                                                part.dirs,
4998                                                part.bytes,
4999                                                part.allocated
5000                                            )),
5001                                            Some((
5002                                                ignored.len() as u64,
5003                                                ignored_dirs,
5004                                                ignored.iter().map(|file| file.1).sum::<u64>(),
5005                                                ignored.iter().map(|file| file.2).sum::<u64>(),
5006                                            )),
5007                                            "ignored {:?}: {context}",
5008                                            node.path
5009                                        );
5010                                        stack.extend(node.children.iter());
5011                                    }
5012                                }
5013                                let represented: Vec<_> = root
5014                                    .iter()
5015                                    .flat_map(|root| root.children.iter().map(|child| &child.path))
5016                                    .collect();
5017                                let uncovered: Vec<_> = selected_files
5018                                    .iter()
5019                                    .filter(|file| {
5020                                        !represented.iter().any(|path| within(file.0, path))
5021                                    })
5022                                    .collect();
5023                                let remainder =
5024                                    TreeRemainder::from_tree(root.as_deref(), omissions);
5025                                if let Some(remainder) = remainder {
5026                                    assert_eq!(
5027                                        (remainder.files, remainder.bytes, remainder.allocated),
5028                                        (
5029                                            Some(uncovered.len() as u64),
5030                                            Some(uncovered.iter().map(|file| file.1).sum::<u64>()),
5031                                            Some(uncovered.iter().map(|file| file.2).sum::<u64>()),
5032                                        ),
5033                                        "remainder: {context}"
5034                                    );
5035                                    assert_eq!(
5036                                        remainder.ignored.map(|part| (part.bytes, part.allocated)),
5037                                        Some((
5038                                            uncovered
5039                                                .iter()
5040                                                .filter(|file| file.3)
5041                                                .map(|file| file.1)
5042                                                .sum(),
5043                                            uncovered
5044                                                .iter()
5045                                                .filter(|file| file.3)
5046                                                .map(|file| file.2)
5047                                                .sum(),
5048                                        )),
5049                                        "remainder ignored: {context}"
5050                                    );
5051                                } else {
5052                                    assert!(uncovered.is_empty(), "missing remainder: {context}");
5053                                }
5054                            }
5055                        }
5056                    }
5057                }
5058            }
5059        }
5060    }
5061
5062    #[test]
5063    fn empty_tree_and_unrepresentable_remainder_stay_honest() {
5064        let empty = Index::new_with_scope("/root", crate::test_support::observing_controls());
5065        let report = run(
5066            &empty,
5067            &query(
5068                &[ViewSpec::Tree],
5069                Selection {
5070                    depth: Some(Bound::Limit(0)),
5071                    min_share: Some(ShareThreshold::parse("1%").expect("share")),
5072                    ..Selection::default()
5073                },
5074            ),
5075        );
5076        let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
5077            panic!("empty tree")
5078        };
5079        assert_eq!((root.files, root.dirs, root.bytes, root.allocated), (0, 0, 0, 0));
5080        assert!(TreeRemainder::from_tree(Some(root), omissions).is_none());
5081
5082        let first = TreeOmission {
5083            reason: TreeOmissionReason::Share,
5084            entries: 1,
5085            files: Some(u64::MAX),
5086            bytes: Some(u64::MAX),
5087            allocated: Some(u64::MAX),
5088            ignored: Some(IgnoredSize { bytes: u64::MAX, allocated: u64::MAX }),
5089        };
5090        let second = TreeOmission {
5091            reason: TreeOmissionReason::Breadth,
5092            entries: 1,
5093            files: Some(1),
5094            bytes: Some(1),
5095            allocated: None,
5096            ignored: Some(IgnoredSize { bytes: 1, allocated: 1 }),
5097        };
5098        let remainder = TreeRemainder::from_tree(None, &[first, second]).expect("omissions");
5099        assert_eq!((remainder.files, remainder.bytes, remainder.allocated), (None, None, None));
5100        assert_eq!(remainder.ignored, None);
5101    }
5102
5103    #[test]
5104    fn unbounded_tree_expands_every_entry_without_a_remainder_or_diagnostics() {
5105        let index = mixed_remainder_index();
5106        let selection = Selection {
5107            size: SizeMetric::Apparent,
5108            depth: Some(Bound::All),
5109            breadth: Some(Bound::All),
5110            limit: Some(Bound::All),
5111            min_share: Some(ShareThreshold::parse("0%").expect("share")),
5112            ..Selection::default()
5113        };
5114        let report = run(&index, &query(&[ViewSpec::Tree], selection));
5115        let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
5116            panic!("expected tree")
5117        };
5118        let mut paths = std::collections::BTreeSet::new();
5119        let mut stack = vec![root.as_ref()];
5120        while let Some(node) = stack.pop() {
5121            assert!(node.omissions.is_empty() && !node.truncated);
5122            paths.insert(node.path.to_string_lossy().replace('\\', "/"));
5123            stack.extend(&node.children);
5124        }
5125        assert_eq!(
5126            paths,
5127            [
5128                "",
5129                "A",
5130                "A/a1",
5131                "A/a1/tiny",
5132                "A/a1/x",
5133                "A/a1/x/leaf",
5134                "A/a2",
5135                "A/a2/file",
5136                "B",
5137                "B/b1",
5138                "B/b1/file",
5139                "B/b2",
5140                "B/b2/file",
5141                "C",
5142                "C/c1",
5143                "C/c1/first",
5144                "C/c1/second"
5145            ]
5146            .into_iter()
5147            .map(str::to_owned)
5148            .collect()
5149        );
5150        assert_eq!((root.files, root.dirs, root.bytes, root.allocated), (7, 9, 10_000, 10_752));
5151        assert!(omissions.is_empty());
5152        assert!(TreeRemainder::from_tree(Some(root), omissions).is_none());
5153        assert!(report.notes.is_empty() && report.tips.is_empty());
5154    }
5155
5156    #[test]
5157    fn an_unlisted_hidden_branch_makes_remainder_counts_and_sizes_unknown() {
5158        let mut index = Index::new("/root");
5159        index.apply_ok(&Observation::new(vec![
5160            upsert("known", EntryKind::File, attrs(100, 0)),
5161            upsert("denied", EntryKind::Dir, attrs(0, 0)),
5162        ]));
5163        index.set_initial_scan_freshness(&[crate::Error::io(
5164            Path::new("/root").join("denied"),
5165            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed listing"),
5166        )]);
5167        let selection = Selection {
5168            size: SizeMetric::Apparent,
5169            depth: Some(Bound::Limit(0)),
5170            min_share: Some(ShareThreshold::parse("0%").expect("share")),
5171            ..Selection::default()
5172        };
5173        let report = run(&index, &query(&[ViewSpec::Tree], selection));
5174        let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
5175            panic!("expected tree")
5176        };
5177        let remainder = TreeRemainder::from_tree(Some(root), omissions).expect("depth bound");
5178        assert_eq!((remainder.files, remainder.bytes, remainder.allocated), (None, None, None));
5179        assert_eq!(remainder.reasons, vec![TreeOmissionReason::Depth]);
5180        let text = crate::report_format::render(&report, crate::report_format::Format::Text, false)
5181            .expect("render");
5182        assert!(text.contains("—     unknown    … and more files (count unknown)"));
5183    }
5184
5185    /// A failed listing must not turn off the default threshold for verified siblings.
5186    /// The whole rendered report is golden; exact arithmetic and unknown retention stay
5187    /// visible together rather than passing as isolated bounds/status assertions.
5188    #[test]
5189    fn partial_tree_keeps_default_share_pruning_golden() {
5190        let mut index = Index::new("/root");
5191        index.apply_ok(&Observation::new(vec![
5192            upsert("large", EntryKind::File, attrs(9898, 0)),
5193            upsert("one-percent", EntryKind::File, attrs(100, 0)),
5194            upsert("tiny", EntryKind::File, attrs(1, 0)),
5195            upsert("zero", EntryKind::File, attrs(0, 0)),
5196            upsert("small", EntryKind::Dir, attrs(0, 0)),
5197            upsert("small/tiny", EntryKind::File, attrs(1, 0)),
5198            upsert("denied", EntryKind::Dir, attrs(0, 0)),
5199        ]));
5200        index.set_initial_scan_freshness(&[crate::Error::io(
5201            Path::new("/root").join("denied"),
5202            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed listing"),
5203        )]);
5204        let selection = Selection { size: SizeMetric::Apparent, ..Selection::default() };
5205        let report = run(&index, &query(&[ViewSpec::Tree], selection.clone()));
5206        assert!(!report.status.complete);
5207        assert_eq!(
5208            crate::report_format::render(&report, crate::report_format::Format::Text, false)
5209                .expect("render"),
5210            include_str!("../../tests/golden/partial-tree.txt"),
5211        );
5212        // A filtered partial tree uses the selected total (102 B), not the whole
5213        // observed root (10,000 B), while reusing selection's subtree measurement.
5214        let filtered = run(
5215            &index,
5216            &query(
5217                &[ViewSpec::Tree],
5218                Selection {
5219                    exclude: vec![pattern("large")],
5220                    min_share: Some(ShareThreshold::parse("50%").expect("share")),
5221                    ..selection.clone()
5222                },
5223            ),
5224        );
5225        let filtered_root = tree_of(&filtered);
5226        assert_eq!(filtered_root.children.len(), 2);
5227        assert_eq!(filtered_root.children[0].name, "one-percent");
5228        assert_eq!(filtered_root.children[1].name, "denied");
5229        let unbounded = run(
5230            &index,
5231            &query(
5232                &[ViewSpec::Tree],
5233                Selection {
5234                    min_share: Some(ShareThreshold::parse("0%").expect("share")),
5235                    ..selection
5236                },
5237            ),
5238        );
5239        assert_eq!(tree_of(&unbounded).children.len(), 6, "zero share lifts only share pruning");
5240    }
5241
5242    #[test]
5243    fn tree_zero_and_composed_caps_keep_typed_first_boundaries() {
5244        let index = sample();
5245        let selection = Selection { limit: Some(Bound::Limit(0)), ..Selection::default() };
5246        let report = run(&index, &query(&[ViewSpec::Tree], selection));
5247        let Section::Tree { root: None, omissions, .. } = &report.sections[0] else {
5248            panic!("zero row cap must omit the root")
5249        };
5250        assert_eq!(omissions[0].reason, TreeOmissionReason::Rows);
5251        assert_eq!(omissions[0].entries, 1);
5252
5253        let selection = Selection {
5254            size: SizeMetric::Apparent,
5255            depth: Some(Bound::Limit(1)),
5256            breadth: Some(Bound::Limit(1)),
5257            limit: Some(Bound::Limit(2)),
5258            min_share: Some(ShareThreshold::parse("1%").expect("share")),
5259            ..Selection::default()
5260        };
5261        let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
5262        assert_eq!(root.children.len(), 1);
5263        assert!(
5264            root.omissions.iter().any(|omission| omission.reason == TreeOmissionReason::Breadth)
5265        );
5266        assert!(
5267            root.children[0]
5268                .omissions
5269                .iter()
5270                .any(|omission| omission.reason == TreeOmissionReason::Depth)
5271        );
5272    }
5273
5274    #[test]
5275    fn requesting_more_views_never_changes_another_views_answer() {
5276        // The property that makes `--view types,tree` one scan and one consistent state.
5277        let index = sample();
5278        let alone = types_of(&run(&index, &query(&[ViewSpec::Extensions], Selection::default())));
5279        let together = run(
5280            &index,
5281            &query(
5282                &[ViewSpec::Extensions, ViewSpec::Tree, ViewSpec::Summary],
5283                Selection::default(),
5284            ),
5285        );
5286        let with_others = match &together.sections[0] {
5287            Section::Extensions { rows, .. } => rows.clone(),
5288            other => panic!("expected types first, got {other:?}"),
5289        };
5290
5291        assert_eq!(alone.len(), with_others.len());
5292        for (left, right) in alone.iter().zip(with_others.iter()) {
5293            assert_eq!(
5294                (&left.extension, left.files, left.bytes),
5295                (&right.extension, right.files, right.bytes)
5296            );
5297        }
5298        assert_eq!(together.sections.len(), 3, "one section per view, in request order");
5299        assert_eq!(together.sections[1].view(), ViewSpec::Tree);
5300        assert_eq!(together.sections[2].view(), ViewSpec::Summary);
5301    }
5302
5303    #[test]
5304    fn analyzed_unfiltered_views_together_match_independent_answers_and_each_view_alone() {
5305        const RUST: &str = "fn main() {\n    println!(\"hi\");\n}\n";
5306        const MARKDOWN: &str = "# Guide\n\nA small useful guide.\n";
5307        const TEXT: &str = "plain notes here\n";
5308        let root = tempfile::tempdir().expect("root");
5309        fs::create_dir_all(root.path().join("src")).expect("src");
5310        fs::create_dir_all(root.path().join("docs")).expect("docs");
5311        fs::write(root.path().join("src/main.rs"), RUST).expect("rust");
5312        fs::write(root.path().join("docs/guide.md"), MARKDOWN).expect("markdown");
5313        fs::write(root.path().join("notes.txt"), TEXT).expect("text");
5314        for (path, seconds) in [("src/main.rs", 10), ("notes.txt", 20), ("docs/guide.md", 30)] {
5315            fs::File::options()
5316                .write(true)
5317                .open(root.path().join(path))
5318                .expect("open for timestamp")
5319                .set_times(
5320                    fs::FileTimes::new().set_modified(UNIX_EPOCH + Duration::from_secs(seconds)),
5321                )
5322                .expect("set timestamp");
5323        }
5324        let (mut index, _) = crate::scan::scan_into_index(
5325            root.path(),
5326            &crate::ScanConfig { read_controls: false, ..crate::ScanConfig::default() },
5327        )
5328        .expect("scan");
5329        crate::content::analyze_index(
5330            &mut index,
5331            crate::content::AnalysisRequest {
5332                profile: AnalysisSet::ALL,
5333                ..crate::content::AnalysisRequest::default()
5334            },
5335        );
5336
5337        let views = [
5338            ViewSpec::Types,
5339            ViewSpec::Families,
5340            ViewSpec::Languages,
5341            ViewSpec::Documents,
5342            ViewSpec::Files,
5343            ViewSpec::Largest,
5344            ViewSpec::Recent,
5345            ViewSpec::Summary,
5346            ViewSpec::Tree,
5347            ViewSpec::Extensions,
5348        ];
5349        let selection = Selection { size: SizeMetric::Apparent, ..Selection::default() };
5350        let together = run(&index, &query(&views, selection.clone()));
5351        for (i, view) in views.iter().enumerate() {
5352            let alone = run(&index, &query(&[*view], selection.clone()));
5353            assert_eq!(
5354                format!("{:?}", together.sections[i]),
5355                format!("{:?}", alone.sections[0]),
5356                "{view:?} changed when requested with the other views"
5357            );
5358        }
5359
5360        for (at, view, files) in [(0, ViewSpec::Types, 3), (1, ViewSpec::Families, 3)] {
5361            let Section::Metrics { view: actual, summary } = &together.sections[at] else {
5362                panic!("expected {view:?} metrics")
5363            };
5364            assert_eq!(*actual, view);
5365            assert_eq!(summary.total.files, files);
5366            assert_eq!(summary.total.analyzed_files, files);
5367        }
5368        let Section::Metrics { summary: languages, .. } = &together.sections[2] else {
5369            panic!("languages")
5370        };
5371        assert_eq!(languages.total.files, 1);
5372        assert_eq!(languages.total.metrics.code_lines, Some(3));
5373        let Section::Metrics { summary: documents, .. } = &together.sections[3] else {
5374            panic!("documents")
5375        };
5376        assert_eq!(documents.total.files, 2);
5377        assert_eq!(documents.total.analyzed_files, 2);
5378        assert_eq!(documents.total.document_metric_files, 2);
5379        assert_eq!(documents.total.document_raw_words, 8);
5380        assert_eq!(documents.total.document_word_stats.logical_words(), 8);
5381        assert_eq!(documents.total.share, MetricShare { numerator: 8, denominator: 8 });
5382
5383        let Section::Files { rows: files, total, .. } = &together.sections[4] else {
5384            panic!("files")
5385        };
5386        assert_eq!(*total, 5);
5387        assert_eq!(
5388            files.iter().map(|row| row.path.as_path()).collect::<Vec<_>>(),
5389            ["docs", "docs/guide.md", "notes.txt", "src", "src/main.rs"].map(Path::new).to_vec()
5390        );
5391        let Section::Files { rows: largest, total, .. } = &together.sections[5] else {
5392            panic!("largest")
5393        };
5394        assert_eq!(*total, 3);
5395        assert_eq!(
5396            largest.iter().map(|row| row.path.as_path()).collect::<Vec<_>>(),
5397            ["src/main.rs", "docs/guide.md", "notes.txt"].map(Path::new).to_vec()
5398        );
5399        let Section::Files { rows: recent, total, .. } = &together.sections[6] else {
5400            panic!("recent")
5401        };
5402        assert_eq!(*total, 3);
5403        assert_eq!(
5404            recent.iter().map(|row| row.path.as_path()).collect::<Vec<_>>(),
5405            ["docs/guide.md", "notes.txt", "src/main.rs"].map(Path::new).to_vec()
5406        );
5407        let Section::Summary(summary) = &together.sections[7] else { panic!("summary") };
5408        assert_eq!((summary.files, summary.dirs), (3, 2));
5409        assert_eq!(
5410            summary.bytes,
5411            u64::try_from(RUST.len() + MARKDOWN.len() + TEXT.len()).expect("fixture bytes")
5412        );
5413        let Section::Tree { root: Some(tree), .. } = &together.sections[8] else { panic!("tree") };
5414        assert_eq!((tree.files, tree.dirs), (3, 2));
5415        let Section::Extensions { rows, total, .. } = &together.sections[9] else {
5416            panic!("extensions")
5417        };
5418        assert_eq!((*total, rows.len()), (3, 3));
5419    }
5420
5421    #[test]
5422    fn one_pass_metric_summaries_match_independent_views() {
5423        let root = tempfile::tempdir().expect("root");
5424        fs::write(root.path().join("main.rs"), "fn main() {}\n").expect("rust");
5425        fs::write(root.path().join("guide.md"), "# Guide\n\nWords.\n").expect("markdown");
5426        fs::write(root.path().join(".gitignore"), "generated/\n").expect("ignore rules");
5427        fs::create_dir(root.path().join("generated")).expect("generated directory");
5428        fs::write(root.path().join("generated/app.js"), "const a = 1;\nconst b = 2;\n")
5429            .expect("ignored code");
5430        fs::write(root.path().join("script"), "#!/bin/sh\necho hello\n").expect("detected code");
5431        let (mut index, _) =
5432            crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
5433        crate::content::analyze_index(
5434            &mut index,
5435            crate::content::AnalysisRequest {
5436                profile: AnalysisSet::ALL,
5437                ..crate::content::AnalysisRequest::default()
5438            },
5439        );
5440
5441        let views = [
5442            ViewSpec::Types,
5443            ViewSpec::Summary,
5444            ViewSpec::Families,
5445            ViewSpec::Languages,
5446            ViewSpec::Documents,
5447            ViewSpec::Code,
5448        ];
5449        let request = query(&views, Selection::default());
5450        let rows = every_entry(&index);
5451        let summaries = metric_summaries(&views, &index, &request, AnalysisSet::ALL, &rows);
5452
5453        assert!(summaries[1].is_none(), "non-metric views keep their own projection");
5454        assert!(summaries[5].is_none(), "Code keeps its admitted-content classification");
5455        for (position, view) in
5456            views.iter().copied().enumerate().filter(|(_, view)| needs_metric_resolution(*view))
5457        {
5458            let independent =
5459                metric_summary(view, &index, &request, AnalysisSet::ALL, None, Some(&rows));
5460            assert_eq!(
5461                format!("{:?}", summaries[position].as_ref().expect("metric summary")),
5462                format!("{independent:?}"),
5463                "{view:?} changed in the one-pass multi-view aggregation"
5464            );
5465        }
5466
5467        // Exercise the integrated population and presentation controls through the report
5468        // boundary. Include uses the shared pass; Exclude and Only use the filtered path.
5469        // Content-detected extensionless code also keeps Code's classification distinct.
5470        // Summary alone does not accept a share threshold, so compare additive views here.
5471        let views = views.into_iter().filter(|view| *view != ViewSpec::Summary).collect::<Vec<_>>();
5472        for ignored in [IgnoredEntries::Include, IgnoredEntries::Exclude, IgnoredEntries::Only] {
5473            for sort in [None, Some(SortKey::Metric("code_lines")), Some(SortKey::Name)] {
5474                for min_share in [None, Some(ShareThreshold::parse("50%").expect("share"))] {
5475                    let selection = Selection {
5476                        ignored,
5477                        sort,
5478                        min_share,
5479                        limit: Some(Bound::Limit(1)),
5480                        ..Selection::default()
5481                    };
5482                    let together = run(&index, &query(&views, selection.clone()));
5483                    for (position, view) in views.iter().enumerate() {
5484                        let alone = run(&index, &query(&[*view], selection.clone()));
5485                        assert_eq!(
5486                            format!("{:?}", together.sections[position]),
5487                            format!("{:?}", alone.sections[0]),
5488                            "{view:?} differs for {selection:?}"
5489                        );
5490                    }
5491                }
5492            }
5493        }
5494    }
5495
5496    #[test]
5497    fn code_overview_keeps_a_complete_language_table_and_population_contributions() {
5498        let root = tempfile::tempdir().expect("root");
5499        fs::create_dir_all(root.path().join("generated")).expect("generated");
5500        fs::write(root.path().join(".gitignore"), "generated/\n").expect("ignore rules");
5501        fs::create_dir_all(root.path().join("src")).expect("src");
5502        fs::write(root.path().join("src/main.rs"), "fn main() {\n}\n// comment\n").expect("rust");
5503        fs::write(
5504            root.path().join("generated/app.js"),
5505            "// Code generated by fixture\nconst answer = 42;\n",
5506        )
5507        .expect("javascript");
5508        fs::write(root.path().join("README.md"), "# Guide\n").expect("documentation");
5509        fs::write(root.path().join("mystery.widget"), "opaque text\n").expect("unknown type");
5510        let (mut index, _) =
5511            crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
5512        crate::content::analyze_index(
5513            &mut index,
5514            crate::content::AnalysisRequest { profile: AnalysisSet::NONE.with_code(), workers: 1 },
5515        );
5516        let answer = run(&index, &query(&[ViewSpec::Code], Selection::default()));
5517        let Section::Code(overview) = &answer.sections[0] else { panic!("code overview") };
5518        assert_eq!(overview.selected.metrics.code_lines, 3);
5519        assert_eq!(overview.selected.metrics.comment_lines, 2);
5520        assert_eq!(overview.selected.analyzed_files, 2);
5521        assert_eq!(overview.analyzed_languages, 2);
5522        assert_eq!(overview.unclassified_files, 2); // .gitignore and mystery.widget
5523        assert_eq!(overview.languages.len(), 2);
5524        assert_eq!(overview.languages[0].share.denominator, 3);
5525        assert_eq!(overview.non_ignored.as_ref().expect("classified").metrics.code_lines, 2);
5526        assert_eq!(overview.ignored.as_ref().expect("classified").metrics.code_lines, 1);
5527        assert_eq!(overview.unknown.source_files, 0);
5528
5529        for (population, expected_lines) in
5530            [(IgnoredEntries::Exclude, 2), (IgnoredEntries::Only, 1)]
5531        {
5532            let selected = run(
5533                &index,
5534                &query(
5535                    &[ViewSpec::Code],
5536                    Selection { ignored: population, ..Selection::default() },
5537                ),
5538            );
5539            let Section::Code(overview) = &selected.sections[0] else { panic!("population") };
5540            assert_eq!(overview.selected.metrics.code_lines, expected_lines);
5541            assert!(overview.non_ignored.is_none() && overview.ignored.is_none());
5542            assert_eq!(overview.unknown.source_files, 0);
5543        }
5544
5545        let threshold = run(
5546            &index,
5547            &query(
5548                &[ViewSpec::Code],
5549                Selection {
5550                    min_share: Some(ShareThreshold::parse("50%").expect("share")),
5551                    ..Selection::default()
5552                },
5553            ),
5554        );
5555        let Section::Code(overview) = &threshold.sections[0] else { panic!("threshold") };
5556        assert_eq!(overview.selected.metrics.code_lines, 3);
5557        assert_eq!(overview.total_languages, 1);
5558        assert_eq!(overview.share_omitted, 1);
5559        assert_eq!(overview.languages[0].language, "rust");
5560
5561        let bounded = run(
5562            &index,
5563            &query(
5564                &[ViewSpec::Code],
5565                Selection { limit: Some(Bound::Limit(0)), ..Selection::default() },
5566            ),
5567        );
5568        let Section::Code(overview) = &bounded.sections[0] else { panic!("bounded") };
5569        assert_eq!(overview.selected.metrics.code_lines, 3);
5570        assert_eq!(overview.total_languages, 2);
5571        assert_eq!(overview.share_omitted, 0);
5572        assert!(overview.languages.is_empty());
5573
5574        let code_and_extensions = run(
5575            &index,
5576            &query(
5577                &[ViewSpec::Languages, ViewSpec::Extensions],
5578                Selection {
5579                    min_share: Some(ShareThreshold::parse("100%").expect("share")),
5580                    ..Selection::default()
5581                },
5582            ),
5583        );
5584        let Section::Metrics { summary, .. } = &code_and_extensions.sections[0] else {
5585            panic!("languages")
5586        };
5587        let Section::Extensions { rows, total, share_omitted } = &code_and_extensions.sections[1]
5588        else {
5589            panic!("extensions")
5590        };
5591        assert_eq!(summary.total.metrics.code_lines, Some(3));
5592        assert!(summary.rows.is_empty() && summary.total_rows == 0);
5593        assert_eq!(summary.share_omitted, 2);
5594        assert!(rows.is_empty() && *total == 0);
5595        assert!(*share_omitted > 0);
5596
5597        let reversed_code = run(
5598            &index,
5599            &query(&[ViewSpec::Code], Selection { reverse: true, ..Selection::default() }),
5600        );
5601        let Section::Code(reversed) = &reversed_code.sections[0] else { panic!("reversed code") };
5602        assert_eq!(reversed.languages[0].language, "javascript");
5603        assert_eq!(reversed.languages[1].language, "rust");
5604
5605        let named_code = run(
5606            &index,
5607            &query(
5608                &[ViewSpec::Code],
5609                Selection { sort: Some(SortKey::Name), ..Selection::default() },
5610            ),
5611        );
5612        let Section::Code(named) = &named_code.sections[0] else { panic!("named code") };
5613        assert_eq!(named.languages[0].language, "javascript");
5614        assert_eq!(named.languages[1].language, "rust");
5615
5616        let sized_code = run(
5617            &index,
5618            &query(
5619                &[ViewSpec::Code],
5620                Selection {
5621                    sort: Some(SortKey::Size),
5622                    size: SizeMetric::Apparent,
5623                    ..Selection::default()
5624                },
5625            ),
5626        );
5627        let Section::Code(sized) = &sized_code.sections[0] else { panic!("sized code") };
5628        assert_eq!(sized.languages[0].language, "javascript");
5629
5630        let files = |reverse| {
5631            let selection = Selection {
5632                kinds: vec![EntryKind::File],
5633                sort: Some(SortKey::Metric("code_lines")),
5634                reverse,
5635                ..Selection::default()
5636            };
5637            run(&index, &query(&[ViewSpec::Files], selection))
5638        };
5639        let descending = files(false);
5640        let Section::Files { rows, .. } = &descending.sections[0] else { panic!("files") };
5641        assert_eq!(rows[0].path, PathBuf::from("src/main.rs"));
5642        assert_eq!(rows[0].sort_value, Some(2));
5643        assert_eq!(rows[1].path, PathBuf::from("generated/app.js"));
5644        assert_eq!(rows[1].sort_value, Some(1));
5645        assert!(rows[2..].iter().all(|row| row.sort_value.is_none()));
5646        let classification = rows[1].classification.as_ref().expect("file classification");
5647        assert!(classification.flags.generated);
5648
5649        let ascending = files(true);
5650        let Section::Files { rows, .. } = &ascending.sections[0] else { panic!("files") };
5651        assert_eq!((rows[0].sort_value, rows[1].sort_value), (Some(1), Some(2)));
5652        assert!(rows[2..].iter().all(|row| row.sort_value.is_none()));
5653
5654        let mut dir_query = query(
5655            &[ViewSpec::List],
5656            Selection {
5657                kinds: vec![EntryKind::Dir],
5658                sort: Some(SortKey::Metric("code_lines")),
5659                ..Selection::default()
5660            },
5661        );
5662        dir_query.format = crate::report_format::Format::Json;
5663        let dirs = run(&index, &dir_query);
5664        let Section::Files { rows, .. } = &dirs.sections[0] else { panic!("directories") };
5665        assert_eq!((rows[0].path.as_path(), rows[0].sort_value), (Path::new("src"), Some(2)));
5666        assert_eq!((rows[1].path.as_path(), rows[1].sort_value), (Path::new("generated"), Some(1)));
5667    }
5668
5669    #[test]
5670    fn code_overview_counts_selected_directory_members_once() {
5671        let root = tempfile::tempdir().expect("root");
5672        fs::create_dir_all(root.path().join("src/deep")).expect("directories");
5673        fs::write(root.path().join("src/main.rs"), "fn main() {}\n").expect("main");
5674        fs::write(root.path().join("src/deep/keep.rs"), "fn keep() {}\n").expect("keep");
5675        fs::write(root.path().join("src/deep/skip.rs"), "fn skip() {}\n").expect("skip");
5676        fs::write(root.path().join("outside.rs"), "fn outside() {}\n").expect("outside");
5677        let (mut index, _) =
5678            crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
5679        crate::content::analyze_index(
5680            &mut index,
5681            crate::content::AnalysisRequest { profile: AnalysisSet::NONE.with_code(), workers: 1 },
5682        );
5683        let selection = Selection {
5684            include: vec![pattern("src"), pattern("deep")],
5685            exclude: vec![pattern("skip.rs")],
5686            ..Selection::default()
5687        };
5688        let answer = run(&index, &query(&[ViewSpec::Code], selection));
5689        let Section::Code(overview) = &answer.sections[0] else { panic!("code overview") };
5690        assert_eq!(overview.selected.source_files, 2);
5691        assert_eq!(overview.selected.analyzed_files, 2);
5692        assert_eq!(overview.selected.metrics.code_lines, 2);
5693        assert_eq!(overview.languages.len(), 1);
5694        assert_eq!(overview.languages[0].selected.source_files, 2);
5695    }
5696
5697    #[test]
5698    fn code_overview_uses_retained_content_detection_for_ambiguous_sources() {
5699        let root = tempfile::tempdir().expect("root");
5700        fs::write(root.path().join("ambiguous.h"), "namespace demo { int value; }\n")
5701            .expect("header");
5702        fs::write(root.path().join("script.inc"), "# vim: set filetype=rust:\nfn main() {}\n")
5703            .expect("modeline");
5704        let (mut index, _) =
5705            crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
5706        crate::content::analyze_index(
5707            &mut index,
5708            crate::content::AnalysisRequest { profile: AnalysisSet::NONE.with_code(), workers: 1 },
5709        );
5710        let answer = run(&index, &query(&[ViewSpec::Code], Selection::default()));
5711        let Section::Code(overview) = &answer.sections[0] else { panic!("code overview") };
5712        assert_eq!(overview.selected.source_files, 2);
5713        assert_eq!(overview.selected.analyzed_files, 2);
5714        assert!(overview.languages.iter().any(|row| row.language == "cpp"));
5715        assert!(overview.languages.iter().any(|row| row.language == "rust"));
5716    }
5717
5718    #[test]
5719    fn refused_control_subtrees_do_not_enter_a_known_ignored_population() {
5720        let root = tempfile::tempdir().expect("root");
5721        fs::create_dir_all(root.path().join("guarded")).expect("directory");
5722        fs::write(root.path().join("known.rs"), "fn known() {}\n").expect("known source");
5723        fs::write(root.path().join("guarded/.gitignore"), "*.rs\n").expect("refused control");
5724        fs::write(root.path().join("guarded/uncertain.rs"), "fn uncertain() {}\n")
5725            .expect("uncertain source");
5726        let config = crate::ScanConfig {
5727            control_limits: crate::control::ControlLimits {
5728                line_limit: Some(1),
5729                ..crate::control::ControlLimits::default()
5730            },
5731            ..crate::ScanConfig::default()
5732        };
5733        let (index, _) = crate::scan::scan_into_index(root.path(), &config).expect("scan");
5734        assert_eq!(index.ignored_classification(Path::new("known.rs")), Some(false));
5735        assert_eq!(index.ignored_classification(Path::new("guarded/uncertain.rs")), None);
5736        for selection in
5737            [Selection::default(), Selection { min_size: Some(0), ..Selection::default() }]
5738        {
5739            let report = run(
5740                &index,
5741                &query(&[ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions], selection),
5742            );
5743            let Section::Summary(summary) = &report.sections[0] else { panic!("summary") };
5744            let Section::Tree { root: Some(tree), .. } = &report.sections[1] else {
5745                panic!("tree")
5746            };
5747            let Section::Extensions { rows, .. } = &report.sections[2] else {
5748                panic!("extensions")
5749            };
5750            assert_eq!(summary.ignored, None);
5751            assert_eq!(tree.ignored, None);
5752            assert_eq!(
5753                tree.children
5754                    .iter()
5755                    .find(|node| node.path == Path::new("known.rs"))
5756                    .and_then(|node| node.ignored),
5757                Some(IgnoredTally::default())
5758            );
5759            assert!(
5760                tree.children
5761                    .iter()
5762                    .find(|node| node.path == Path::new("guarded"))
5763                    .is_some_and(|node| node.ignored.is_none())
5764            );
5765            assert!(rows.iter().all(|row| row.ignored.is_none()));
5766            assert!(
5767                report
5768                    .notes
5769                    .iter()
5770                    .any(|note| note.contains("gitignored subtotals are unavailable"))
5771            );
5772            assert!(
5773                crate::report_format::report_notes(&report)
5774                    .iter()
5775                    .all(|note| !note.contains("include gitignored sizes"))
5776            );
5777        }
5778        let hidden_report = run(
5779            &index,
5780            &query(
5781                &[ViewSpec::Tree],
5782                Selection { depth: Some(Bound::Limit(0)), ..Selection::default() },
5783            ),
5784        );
5785        let Section::Tree { root, omissions, .. } = &hidden_report.sections[0] else {
5786            panic!("expected tree")
5787        };
5788        let hidden = TreeRemainder::from_tree(root.as_deref(), omissions).expect("hidden rows");
5789        assert!(hidden.bytes.is_some() && hidden.ignored.is_none());
5790        for population in [IgnoredEntries::Exclude, IgnoredEntries::Only] {
5791            let answer = run(
5792                &index,
5793                &query(
5794                    &[ViewSpec::Files],
5795                    Selection {
5796                        kinds: vec![EntryKind::File],
5797                        ignored: population,
5798                        ..Selection::default()
5799                    },
5800                ),
5801            );
5802            let Section::Files { rows, .. } = &answer.sections[0] else { panic!("files") };
5803            assert!(rows.iter().all(|row| !row.path.starts_with("guarded")));
5804            assert_eq!(
5805                rows.iter().any(|row| row.path == Path::new("known.rs")),
5806                population == IgnoredEntries::Exclude
5807            );
5808        }
5809    }
5810
5811    #[test]
5812    fn a_report_derives_provenance_from_its_index() {
5813        let index = sample();
5814        let report = run(&index, &query(&[ViewSpec::Summary], Selection::default()));
5815        assert_eq!(report.provenance.source, ReportSource::ColdScan);
5816        assert!(report.status.complete);
5817        assert!(report.provenance.scan_started_at.is_some());
5818        assert_eq!(report.provenance.generated_at, generated_at());
5819        assert_eq!(report.root, Path::new("/root"));
5820    }
5821
5822    #[test]
5823    fn reporting_is_pure_and_repeatable() {
5824        let index = sample();
5825        let request = query(&[ViewSpec::Tree, ViewSpec::Extensions], Selection::default());
5826        assert_eq!(format!("{:?}", run(&index, &request)), format!("{:?}", run(&index, &request)));
5827    }
5828
5829    #[test]
5830    fn metadata_grouping_views_use_the_generic_metric_projection() {
5831        let index = sample();
5832        let apparent = Selection { size: SizeMetric::Apparent, ..Selection::default() };
5833        let report = run(
5834            &index,
5835            &query(&[ViewSpec::Types, ViewSpec::Families, ViewSpec::Languages], apparent),
5836        );
5837        let Section::Metrics { summary: types, .. } = &report.sections[0] else {
5838            panic!("expected type metrics")
5839        };
5840        let rust = types.rows.iter().find(|row| row.id == "rust").expect("rust");
5841        assert_eq!((rust.files, rust.bytes), (3, 350));
5842        assert_eq!((rust.share.numerator, rust.share.denominator), (350, 657));
5843        assert_eq!(types.share_metric, ShareMetric::ApparentBytes);
5844
5845        let Section::Metrics { summary: families, .. } = &report.sections[1] else {
5846            panic!("expected family metrics")
5847        };
5848        assert!(families.rows.iter().any(|row| row.id == "code"));
5849        assert!(families.rows.iter().any(|row| row.id == "prose"));
5850        assert_eq!(families.share_metric, ShareMetric::ApparentBytes);
5851
5852        let Section::Metrics { summary: languages, .. } = &report.sections[2] else {
5853            panic!("expected language metrics")
5854        };
5855        let rust = languages.rows.iter().find(|row| row.id == "rust").expect("rust");
5856        assert_eq!((rust.files, rust.bytes), (3, 350));
5857        assert_eq!((rust.share.numerator, rust.share.denominator), (350, 350));
5858        assert_eq!(languages.share_metric, ShareMetric::ApparentBytes);
5859    }
5860
5861    /// The control file's name, spelled once for the fixtures that write one.
5862    const CONTROL: &str = ".gitignore";
5863
5864    /// A small tree under a control source ignoring `build/` and `*.log`.
5865    fn classified_sample() -> Index {
5866        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
5867        index
5868            .apply(&Observation::new(vec![
5869                Op::ControlUpsert {
5870                    path: PathBuf::from(CONTROL),
5871                    source: b"build/\n*.log\n".to_vec(),
5872                },
5873                upsert("src", EntryKind::Dir, Attrs::default()),
5874                upsert("src/main.rs", EntryKind::File, attrs(100, 10)),
5875                upsert("src/lib.rs", EntryKind::File, attrs(200, 20)),
5876                upsert("src/debug.log", EntryKind::File, attrs(25, 70)),
5877                upsert("docs", EntryKind::Dir, Attrs::default()),
5878                upsert("docs/guide.md", EntryKind::File, attrs(300, 30)),
5879                upsert("build", EntryKind::Dir, Attrs::default()),
5880                upsert("build/cache", EntryKind::Dir, Attrs::default()),
5881                upsert("build/cache/out.bin", EntryKind::File, attrs(1_000, 60)),
5882            ]))
5883            .expect("apply");
5884        index
5885    }
5886
5887    /// H186: a directory's children are admitted from their roll-ups before any row is
5888    /// built, in the order sorting every row would give; the children the share omits are
5889    /// summed from their facts alone, an ignored tally included; an incomplete child below
5890    /// the share is admitted regardless; and `complete = false` withholds the sums.
5891    #[test]
5892    fn child_rows_admit_from_roll_ups_and_sum_what_the_share_omits() {
5893        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
5894        index.apply_ok(&Observation::new(vec![
5895            Op::ControlUpsert { path: PathBuf::from(CONTROL), source: b"*.log\n".to_vec() },
5896            upsert("big", EntryKind::Dir, Attrs::default()),
5897            upsert("big/a", EntryKind::File, attrs(1_000, 10)),
5898            upsert("small", EntryKind::Dir, Attrs::default()),
5899            upsert("small/x.log", EntryKind::File, attrs(5, 20)),
5900            upsert("tiny", EntryKind::Dir, Attrs::default()),
5901            upsert("tiny/t", EntryKind::File, attrs(3, 30)),
5902            upsert("f1", EntryKind::File, attrs(200, 40)),
5903            upsert("f2", EntryKind::File, attrs(2, 50)),
5904        ]));
5905        let id = |path: &str| index.lookup(Path::new(path)).expect("an indexed path");
5906        let measured = |bytes: u64, complete| query_subtrees::SubtreeValues {
5907            bytes,
5908            allocated: bytes.div_ceil(512) * 512,
5909            mtime_ns: 0,
5910            files: 1,
5911            dirs: 0,
5912            complete,
5913        };
5914        let measurements: BTreeMap<EntryId, query_subtrees::SubtreeValues> = [
5915            (id("big"), measured(1_000, true)),
5916            (id("small"), measured(5, true)),
5917            (id("tiny"), measured(3, false)),
5918        ]
5919        .into_iter()
5920        .collect();
5921        // Apparent bytes, so the shares below are the file sizes' rather than their
5922        // allocated blocks', which round every small file up to one.
5923        let query = query(
5924            &[ViewSpec::Tree],
5925            Selection { size: SizeMetric::Apparent, ..Selection::default() },
5926        );
5927        let grand = 1_000 + 5 + 3 + 200 + 2;
5928
5929        let (rows, omitted) = child_rows(
5930            &index,
5931            &query,
5932            None,
5933            &BTreeMap::new(),
5934            Some(&measurements),
5935            EntryId::ROOT,
5936            Path::new(""),
5937            &ShareThreshold::one_percent(),
5938            grand,
5939        );
5940        // 1% of 1,210 is 12.1: `big` and `f1` clear it, `tiny` does not but is incomplete,
5941        // and the rows come sorted by size, largest first, as the tree sorts them.
5942        assert_eq!(
5943            rows.iter().map(|(row, id)| (row.name.as_str(), row.bytes, *id)).collect::<Vec<_>>(),
5944            [("big", 1_000, id("big")), ("f1", 200, id("f1")), ("tiny", 3, id("tiny"))]
5945        );
5946        assert!(rows.iter().all(|(row, _)| row.path.as_path() == Path::new(&row.name)));
5947        // `small` and `f2` are below the share: their facts alone, `small` with the ignored
5948        // file it holds and `f2` with an empty tally, since its classification is known.
5949        let mut facts: Vec<_> =
5950            omitted.iter().map(|row| (row.bytes, row.files, row.allocated, row.ignored)).collect();
5951        facts.sort_unstable_by_key(|(bytes, ..)| *bytes);
5952        assert_eq!(
5953            facts,
5954            [
5955                (2, 1, 512, Some(IgnoredTally::default())),
5956                (5, 1, 512, Some(IgnoredTally { files: 1, dirs: 0, bytes: 5, allocated: 512 })),
5957            ]
5958        );
5959
5960        for complete in [true, false] {
5961            let mut node = TreeNode {
5962                path: PathBuf::new(),
5963                name: String::new(),
5964                kind: EntryKind::Dir,
5965                entry_ignored: None,
5966                bytes: grand,
5967                allocated: 0,
5968                files: 5,
5969                dirs: 3,
5970                ignored: None,
5971                newest_mtime_ns: None,
5972                children: Vec::new(),
5973                omissions: Vec::new(),
5974                truncated: false,
5975            };
5976            record_omission(&mut node, TreeOmissionReason::Share, &omitted, None, complete);
5977            assert!(node.truncated);
5978            let [omission] = node.omissions.as_slice() else {
5979                panic!("one Share omission, complete = {complete}: {:?}", node.omissions)
5980            };
5981            assert_eq!(omission.reason, TreeOmissionReason::Share);
5982            assert_eq!(omission.entries, 2);
5983            let sums = (omission.files, omission.bytes, omission.allocated, omission.ignored);
5984            let expected = if complete {
5985                (Some(2), Some(7), Some(1_024), Some(IgnoredSize { bytes: 5, allocated: 512 }))
5986            } else {
5987                (None, None, None, None)
5988            };
5989            assert_eq!(sums, expected, "complete = {complete}");
5990        }
5991    }
5992
5993    #[test]
5994    fn tree_entry_classification_is_independent_of_selected_subtree_tallies() {
5995        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
5996        index.apply_ok(&Observation::new(vec![
5997            Op::ControlUpsert {
5998                path: PathBuf::from(CONTROL),
5999                source: b"empty/\nzero.txt\n*.log\n".to_vec(),
6000            },
6001            upsert("empty", EntryKind::Dir, Attrs::default()),
6002            upsert("zero.txt", EntryKind::File, attrs(0, 0)),
6003            upsert("mixed", EntryKind::Dir, Attrs::default()),
6004            upsert("mixed/ignored.log", EntryKind::File, attrs(5, 5)),
6005            upsert("mixed/ordinary.rs", EntryKind::File, attrs(7, 7)),
6006        ]));
6007        let selection = |ignored| Selection {
6008            ignored,
6009            depth: Some(Bound::All),
6010            breadth: Some(Bound::All),
6011            limit: Some(Bound::All),
6012            min_share: Some(ShareThreshold::parse("0%").expect("share")),
6013            ..Selection::default()
6014        };
6015        let included =
6016            tree_of(&run(&index, &query(&[ViewSpec::Tree], selection(IgnoredEntries::Include))));
6017        assert_eq!(included.entry_ignored, Some(false));
6018        let child = |name: &str| included.children.iter().find(|row| row.name == name).expect(name);
6019        let empty = child("empty");
6020        assert_eq!((empty.kind, empty.bytes, empty.entry_ignored), (EntryKind::Dir, 0, Some(true)));
6021        assert_eq!(child("zero.txt").entry_ignored, Some(true));
6022        let mixed = child("mixed");
6023        assert_eq!(mixed.entry_ignored, Some(false));
6024        assert_eq!(mixed.ignored.expect("known").bytes, 5);
6025        assert_eq!(
6026            mixed
6027                .children
6028                .iter()
6029                .find(|row| row.name == "ignored.log")
6030                .expect("ignored leaf")
6031                .entry_ignored,
6032            Some(true)
6033        );
6034
6035        let only =
6036            tree_of(&run(&index, &query(&[ViewSpec::Tree], selection(IgnoredEntries::Only))));
6037        assert_eq!(only.entry_ignored, Some(false));
6038        let mixed_only = only.children.iter().find(|row| row.name == "mixed").expect("ancestor");
6039        assert_eq!(mixed_only.entry_ignored, Some(false));
6040        assert_eq!(mixed_only.ignored.expect("known").bytes, 5);
6041
6042        let mut unobserved =
6043            Index::new_with_scope("/root", crate::test_support::not_observing_controls());
6044        unobserved.apply_ok(&Observation::new(vec![upsert(
6045            "plain",
6046            EntryKind::Dir,
6047            Attrs::default(),
6048        )]));
6049        let unknown = tree_of(&run(
6050            &unobserved,
6051            &query(&[ViewSpec::Tree], selection(IgnoredEntries::Include)),
6052        ));
6053        assert_eq!(unknown.entry_ignored, None);
6054        assert_eq!(unknown.children[0].entry_ignored, None);
6055    }
6056
6057    #[test]
6058    fn ignored_size_interpretation_note_appears_once_only_when_relevant() {
6059        let classified = classified_sample();
6060        for (population, expected) in
6061            [(IgnoredEntries::Include, 1), (IgnoredEntries::Exclude, 0), (IgnoredEntries::Only, 0)]
6062        {
6063            let report = run(
6064                &classified,
6065                &query(
6066                    &[ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions],
6067                    Selection {
6068                        ignored: population,
6069                        depth: Some(Bound::All),
6070                        min_share: Some(ShareThreshold::parse("0%").expect("share")),
6071                        ..Selection::default()
6072                    },
6073                ),
6074            );
6075            let notes = crate::report_format::report_notes(&report);
6076            assert_eq!(
6077                notes.iter().filter(|note| note.contains("include gitignored sizes")).count(),
6078                expected,
6079                "{population:?}: {notes:?}"
6080            );
6081        }
6082        let mut unobserved =
6083            Index::new_with_scope("/root", crate::test_support::not_observing_controls());
6084        unobserved.apply_ok(&Observation::new(vec![upsert(
6085            "plain.rs",
6086            EntryKind::File,
6087            attrs(10, 0),
6088        )]));
6089        let blind =
6090            run(&unobserved, &query(&[ViewSpec::Summary, ViewSpec::Tree], Selection::default()));
6091        assert!(
6092            crate::report_format::report_notes(&blind)
6093                .iter()
6094                .all(|note| !note.contains("include gitignored sizes"))
6095        );
6096    }
6097
6098    #[test]
6099    fn tree_remainder_counts_only_unrepresented_root_children() {
6100        let index = classified_sample();
6101        let remainder = |selection| {
6102            let report = run(&index, &query(&[ViewSpec::Tree], selection));
6103            let Section::Tree { root, omissions, .. } = &report.sections[0] else {
6104                panic!("expected tree")
6105            };
6106            TreeRemainder::from_tree(root.as_deref(), omissions).expect("hidden rows")
6107        };
6108        let mixed = remainder(Selection {
6109            size: SizeMetric::Apparent,
6110            depth: Some(Bound::Limit(1)),
6111            breadth: Some(Bound::Limit(2)),
6112            min_share: Some(ShareThreshold::parse("5%").expect("share")),
6113            ..Selection::default()
6114        });
6115        let usage = |ignored: Option<IgnoredSize>| ignored.map(|part| (part.bytes, part.allocated));
6116        assert_eq!(
6117            (mixed.files, mixed.bytes, usage(mixed.ignored)),
6118            (Some(1), Some(300), Some((0, 0)))
6119        );
6120        assert_eq!(mixed.reasons, vec![TreeOmissionReason::Breadth]);
6121
6122        for limit in [Bound::Limit(1), Bound::Limit(0)] {
6123            let rows = remainder(Selection { limit: Some(limit), ..Selection::default() });
6124            assert_eq!((rows.bytes, usage(rows.ignored)), (Some(1_625), Some((1_025, 1_536))));
6125        }
6126        let excluded = remainder(Selection {
6127            depth: Some(Bound::Limit(0)),
6128            ignored: IgnoredEntries::Exclude,
6129            ..Selection::default()
6130        });
6131        assert_eq!((excluded.bytes, excluded.ignored), (Some(600), Some(IgnoredSize::default())));
6132        let only = remainder(Selection {
6133            depth: Some(Bound::Limit(0)),
6134            ignored: IgnoredEntries::Only,
6135            ..Selection::default()
6136        });
6137        assert_eq!((only.bytes, usage(only.ignored)), (Some(1_025), Some((1_025, 1_536))));
6138    }
6139
6140    #[test]
6141    fn colored_tree_populations_share_one_golden() {
6142        let classified = classified_sample();
6143        let mut unobserved =
6144            Index::new_with_scope("/root", crate::test_support::not_observing_controls());
6145        unobserved.apply_ok(&Observation::new(vec![upsert(
6146            "plain.rs",
6147            EntryKind::File,
6148            attrs(10, 0),
6149        )]));
6150
6151        let root = tempfile::tempdir().expect("root");
6152        fs::create_dir(root.path().join("guarded")).expect("directory");
6153        fs::write(root.path().join("known.rs"), "K").expect("known file");
6154        fs::write(root.path().join("guarded/.gitignore"), "*.rs\n").expect("control");
6155        fs::write(root.path().join("guarded/uncertain.rs"), "U").expect("uncertain file");
6156        let config = crate::ScanConfig {
6157            control_limits: crate::control::ControlLimits {
6158                line_limit: Some(1),
6159                ..crate::control::ControlLimits::default()
6160            },
6161            ..crate::ScanConfig::default()
6162        };
6163        let (refused, _) = crate::scan::scan_into_index(root.path(), &config).expect("scan");
6164        assert_eq!(refused.ignored_classification(Path::new("known.rs")), Some(false));
6165        assert_eq!(refused.ignored_classification(Path::new("guarded/uncertain.rs")), None);
6166
6167        let selection = |ignored| Selection {
6168            size: SizeMetric::Apparent,
6169            depth: Some(Bound::Limit(0)),
6170            ignored,
6171            ..Selection::default()
6172        };
6173        let cases = [
6174            ("INCLUDE", &classified, selection(IgnoredEntries::Include)),
6175            (
6176                "INCLUDE EXPANDED",
6177                &classified,
6178                Selection {
6179                    depth: Some(Bound::Limit(2)),
6180                    min_share: Some(ShareThreshold::parse("0%").expect("share")),
6181                    ..selection(IgnoredEntries::Include)
6182                },
6183            ),
6184            ("EXCLUDE", &classified, selection(IgnoredEntries::Exclude)),
6185            ("ONLY", &classified, selection(IgnoredEntries::Only)),
6186            ("NO CONTROLS", &unobserved, selection(IgnoredEntries::Include)),
6187            ("REFUSED CONTROL", &refused, selection(IgnoredEntries::Include)),
6188        ];
6189        let mut actual = String::new();
6190        for (label, index, selection) in cases {
6191            let report = run(index, &query(&[ViewSpec::Tree], selection));
6192            actual.push_str(label);
6193            actual.push('\n');
6194            actual.push_str(
6195                &crate::report_format::render(&report, crate::report_format::Format::Text, true)
6196                    .expect("colored tree")
6197                    .replace('\u{1b}', "<ESC>"),
6198            );
6199            actual.push('\n');
6200        }
6201        assert_eq!(actual, include_str!("../../tests/golden/tree-populations.txt"));
6202    }
6203
6204    #[test]
6205    fn tree_remainder_ignored_share_is_unknown_without_control_observation() {
6206        let mut index =
6207            Index::new_with_scope("/root", crate::test_support::not_observing_controls());
6208        index.apply_ok(&Observation::new(vec![upsert("file", EntryKind::File, attrs(10, 0))]));
6209        let report = run(
6210            &index,
6211            &query(
6212                &[ViewSpec::Tree],
6213                Selection { depth: Some(Bound::Limit(0)), ..Selection::default() },
6214            ),
6215        );
6216        let Section::Tree { root, omissions, .. } = &report.sections[0] else {
6217            panic!("expected tree")
6218        };
6219        let hidden = TreeRemainder::from_tree(root.as_deref(), omissions).expect("hidden file");
6220        assert_eq!((hidden.bytes, hidden.ignored), (Some(10), None));
6221    }
6222
6223    fn ignored_of(row: &SummaryRow) -> IgnoredTally {
6224        row.ignored.expect("an observing index reports an ignored share")
6225    }
6226
6227    /// Both tiers report the same ignored share on every row kind that carries one: the
6228    /// unfiltered tier subtracts the maintained partitions, and the traversal tier counts
6229    /// the entries it admits.
6230    #[test]
6231    fn the_two_tiers_agree_on_the_ignored_share() {
6232        let index = classified_sample();
6233        let expected = IgnoredTally { files: 2, dirs: 2, bytes: 1_025, allocated: 1_024 + 512 };
6234        for selection in
6235            [Selection::default(), Selection { min_size: Some(0), ..Selection::default() }]
6236        {
6237            let unfiltered = selection.is_unfiltered();
6238            let summary = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection.clone())));
6239            assert_eq!(ignored_of(&summary), expected, "unfiltered: {unfiltered}");
6240
6241            let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection.clone())));
6242            assert_eq!(tree.ignored, Some(expected), "unfiltered: {unfiltered}");
6243            let child = |name: &str| {
6244                tree.children.iter().find(|node| node.name == name).expect(name).ignored
6245            };
6246            assert_eq!(
6247                child("build"),
6248                Some(IgnoredTally { files: 1, dirs: 1, bytes: 1_000, allocated: 1_024 }),
6249                "an ignored directory is wholly ignored below it, unfiltered: {unfiltered}"
6250            );
6251            assert_eq!(
6252                child("src"),
6253                Some(IgnoredTally { files: 1, dirs: 0, bytes: 25, allocated: 512 }),
6254                "unfiltered: {unfiltered}"
6255            );
6256            assert_eq!(child("docs"), Some(IgnoredTally::default()), "observed, nothing ignored");
6257
6258            let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], selection.clone())));
6259            let row = |extension: &str| {
6260                rows.iter().find(|row| row.extension == extension).expect(extension).ignored
6261            };
6262            assert_eq!(
6263                row(".log"),
6264                Some(IgnoredTally { files: 1, dirs: 0, bytes: 25, allocated: 512 }),
6265                "unfiltered: {unfiltered}"
6266            );
6267            assert_eq!(row(".rs"), Some(IgnoredTally::default()), "unfiltered: {unfiltered}");
6268
6269            let files = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
6270            let flag =
6271                |path: PathBuf| files.iter().find(|row| row.path == path).map(|row| row.ignored);
6272            assert_eq!(flag(PathBuf::from("build")), Some(Some(true)));
6273            assert_eq!(flag(PathBuf::from("src")), Some(Some(false)));
6274            assert_eq!(flag(["src", "debug.log"].iter().collect()), Some(Some(true)));
6275            assert_eq!(flag(["build", "cache", "out.bin"].iter().collect()), Some(Some(true)));
6276        }
6277    }
6278
6279    /// Excluding ignored entries and selecting only them split the tree into two parts that
6280    /// sum to it, and each sizes and ranks its rows by what it selected.
6281    #[test]
6282    fn ignored_entries_partition_the_tree_and_rank_by_what_they_select() {
6283        let index = classified_sample();
6284        // Apparent, so the ranking is by the distinct sizes the sample wrote rather than by
6285        // the 512-byte blocks they round up to.
6286        let apparent = Selection { size: SizeMetric::Apparent, ..Selection::default() };
6287        let with = |ignored| Selection { ignored, ..apparent.clone() };
6288        let summary = |selection| summary_of(&run(&index, &query(&[ViewSpec::Summary], selection)));
6289        let total = summary(apparent.clone());
6290        let kept = summary(with(IgnoredEntries::Exclude));
6291        let only = summary(with(IgnoredEntries::Only));
6292        assert_eq!(
6293            (kept.files + only.files, kept.dirs + only.dirs, kept.bytes + only.bytes),
6294            (total.files, total.dirs, total.bytes)
6295        );
6296        assert_eq!(ignored_of(&kept), IgnoredTally::default());
6297        let whole = ignored_of(&only);
6298        assert_eq!((whole.files, whole.dirs, whole.bytes), (only.files, only.dirs, only.bytes));
6299
6300        let ranked = |selection| {
6301            tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)))
6302                .children
6303                .iter()
6304                .map(|node| (node.name.clone(), node.bytes))
6305                .collect::<Vec<_>>()
6306        };
6307        let row = |name: &str, bytes: u64| (name.to_string(), bytes);
6308        assert_eq!(
6309            ranked(apparent.clone()),
6310            [row("build", 1_000), row("src", 325), row("docs", 300)]
6311        );
6312        assert_eq!(
6313            ranked(with(IgnoredEntries::Exclude)),
6314            [row("docs", 300), row("src", 300)],
6315            "unignored sizes rank the rows, with the name breaking the tie"
6316        );
6317        assert_eq!(ranked(with(IgnoredEntries::Only)), [row("build", 1_000), row("src", 25)]);
6318    }
6319
6320    /// An index that read no rule reports no ignored share on any row, and refuses a
6321    /// selection by ignored state in the vocabulary of the surface that asked.
6322    #[test]
6323    fn an_index_that_observed_no_control_state_has_no_ignored_share_to_select_by() {
6324        let mut index =
6325            Index::new_with_scope("/root", crate::test_support::not_observing_controls());
6326        index
6327            .apply(&Observation::new(vec![
6328                upsert("build", EntryKind::Dir, Attrs::default()),
6329                upsert("build/out.bin", EntryKind::File, attrs(1_000, 60)),
6330            ]))
6331            .expect("apply");
6332        let views = [ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions, ViewSpec::Files];
6333        let report = run(&index, &query(&views, Selection::default()));
6334        let Section::Summary(summary) = &report.sections[0] else { panic!("a summary") };
6335        let Section::Tree { root: Some(tree), .. } = &report.sections[1] else { panic!("a tree") };
6336        let Section::Extensions { rows: extensions, .. } = &report.sections[2] else {
6337            panic!("extensions")
6338        };
6339        let Section::Files { rows: files, .. } = &report.sections[3] else { panic!("files") };
6340        assert_eq!(summary.ignored, None);
6341        assert_eq!(tree.ignored, None);
6342        assert!(tree.children.iter().all(|node| node.ignored.is_none()));
6343        assert!(extensions.iter().all(|row| row.ignored.is_none()));
6344        assert!(files.iter().all(|row| row.ignored.is_none()));
6345
6346        let exclude = Query {
6347            selection: Selection { ignored: IgnoredEntries::Exclude, ..Selection::default() },
6348            views: vec![ViewSpec::Summary],
6349            ..Query::default()
6350        };
6351        let only = Query {
6352            selection: Selection { ignored: IgnoredEntries::Only, ..Selection::default() },
6353            ..exclude.clone()
6354        };
6355        // The request model refuses such a request before it reaches a reader, in each
6356        // surface's words (`query_request`'s tests). The library path refuses it too, rather
6357        // than answering with no rows: a caller that reaches `report` without validating gets
6358        // the same typed refusal every other surface renders.
6359        for refused in [exclude, only] {
6360            assert!(
6361                matches!(
6362                    super::report(
6363                        &index,
6364                        &crate::test_support::read_of(&index, refused),
6365                        generated_at()
6366                    ),
6367                    Err(crate::Error::InvalidRequest(
6368                        crate::query::RequestError::IgnoredWithoutObservation(_)
6369                    ))
6370                ),
6371                "a selection by ignored state over an unobserving index is refused"
6372            );
6373        }
6374        assert_eq!(summary_of(&run(&index, &query(&views, Selection::default()))).files, 1);
6375    }
6376}