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