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 matches!(&ignore_rules, ControlCoverage::Observed(observed) if observed.refusals.len() > REFUSED_DIRECTORIES_NAMED)
1566    {
1567        tips.push(format!("tip: show retained ignore-file details: {} json", query.axes.format));
1568    }
1569    if tree_measurements.is_some_and(|values| values.values().any(|value| !value.complete)) {
1570        notes.push("note: incomplete subtrees remain visible below the size threshold".to_owned());
1571    }
1572    if index.observes_controls() && !index.ignored_classification_complete_below(Path::new("")) {
1573        notes.push(
1574            "note: gitignored subtotals are unavailable where governing rules could not be verified"
1575                .to_owned(),
1576        );
1577    }
1578    Ok(Report {
1579        age_reference_ns,
1580        format: query.format,
1581        notes,
1582        tips,
1583        axes: query.axes,
1584        status: TreeStatus::of(index, request),
1585        provenance: ReportProvenance::of(index, content, generated_at),
1586        scope: index.scope(),
1587        requested_analysis: content,
1588        requested_views: query.views.clone(),
1589        omitted_views: query.omitted_views.clone(),
1590        root: index.root_path().to_path_buf(),
1591        size: query.selection.size,
1592        sort_metric: match query.selection.sort {
1593            Some(SortKey::Metric(name)) => Some(name),
1594            _ => None,
1595        },
1596        analysis: index.content().and_then(|held| {
1597            let wanted = index.content_identity(content);
1598            let projected = held.admit(&wanted)?;
1599            Some(ContentReportMetadata {
1600                profile: projected.identity().analysis,
1601                provenance: projected.identity().record_provenance(),
1602            })
1603        }),
1604        ignored_entries: query.selection.ignored,
1605        ignore_rules,
1606        sections,
1607    })
1608}
1609
1610/// Build a one-section report from an already reduced exact summary.
1611///
1612/// Pure for the same reason as [`report`]: scanning and time sampling happened before
1613/// this boundary.  The execution planner uses this when a one-shot request proves that
1614/// retaining paths and hierarchy cannot affect its answer.
1615pub(crate) fn report_summary(
1616    root: &Path,
1617    scope: ScanScope,
1618    request: &Request,
1619    summary: SummaryRow,
1620    status: TreeStatus,
1621    provenance: ReportProvenance,
1622) -> Report {
1623    Report {
1624        age_reference_ns: crate::query::system_time_to_nanos(request.now),
1625        format: request.query.format,
1626        // A compact summary resolves one view and drops none.
1627        notes: Vec::new(),
1628        tips: Vec::new(),
1629        axes: request.query.axes,
1630        status,
1631        provenance,
1632        scope,
1633        requested_analysis: AnalysisSet::NONE,
1634        requested_views: vec![ViewSpec::Summary],
1635        omitted_views: Vec::new(),
1636        root: root.to_path_buf(),
1637        size: request.query.selection.size,
1638        sort_metric: None,
1639        // The planner only selects this tier when no analysis was requested, so there is
1640        // no analyzer provenance to report.
1641        analysis: None,
1642        // An unfiltered summary selects every entry.
1643        ignored_entries: IgnoredEntries::Include,
1644        // Nor when control state is observed, since it retains no table to classify with.
1645        ignore_rules: ControlCoverage::NotObserved,
1646        sections: vec![Section::Summary(SummaryRow { ignored: None, ..summary })],
1647    }
1648}
1649
1650/// Aggregates gathered by one filtered traversal.
1651struct Walked {
1652    /// Whether the walked index observed control state, so its rows carry ignored shares.
1653    observed: bool,
1654    /// Filtered subtree aggregates, keyed by directory id.
1655    ///
1656    /// A row's `ignored` stays `None` until an ignored entry is admitted beneath it;
1657    /// [`Self::summary_of`] is what reads it as the index's observation says.
1658    per_directory: BTreeMap<EntryId, SummaryRow>,
1659    /// Filtered per-extension tallies.
1660    by_ext: BTreeMap<String, ExtTally>,
1661    /// The ignored part of each filtered per-extension tally, for extensions that have one.
1662    ignored_by_ext: BTreeMap<String, ExtTally>,
1663    /// Entries the selection admitted.
1664    rows: Vec<FileRow>,
1665    /// Regular files in the union of matches and selected subtrees, counted once.
1666    members: Vec<FileRow>,
1667    /// Directories in that union or on a path to it, including empty matches.
1668    visible: BTreeSet<EntryId>,
1669    visible_files: BTreeSet<EntryId>,
1670    /// Selected contents whose ignore classification is unavailable, by ancestor directory.
1671    unknown_ignored: BTreeSet<EntryId>,
1672}
1673
1674impl Walked {
1675    /// One directory's filtered totals, with an ignored share exactly when observed.
1676    fn summary_of(&self, id: EntryId) -> SummaryRow {
1677        let mut row = self.per_directory.get(&id).copied().unwrap_or_default();
1678        row.ignored = (self.observed && !self.unknown_ignored.contains(&id))
1679            .then(|| row.ignored.unwrap_or_default());
1680        row
1681    }
1682}
1683
1684/// One directory's unfiltered totals from the roll-up state the index maintains, with its
1685/// ignored share, `all` less `unignored`, when the index observed control state.
1686fn unfiltered_summary(index: &Index, id: EntryId, path: &Path) -> SummaryRow {
1687    let observed = index.observes_controls() && index.ignored_classification_complete_below(path);
1688    let Some((all, unignored)) = index.partition_scalars_of(id) else {
1689        return SummaryRow {
1690            ignored: observed.then(IgnoredTally::default),
1691            ..SummaryRow::default()
1692        };
1693    };
1694    SummaryRow {
1695        ignored: observed.then(|| IgnoredTally::between(all, unignored)),
1696        ..summary_from_scalars(all)
1697    }
1698}
1699
1700/// Walk the retained index once, aggregating only what the selection admits.
1701///
1702/// Iterative rather than recursive: this engine is built for trees deep enough that a
1703/// recursive post-order would exhaust the stack.
1704fn walk(
1705    index: &Index,
1706    selection: &Selection,
1707    identity: NameIdentity,
1708    directories: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
1709) -> Walked {
1710    let observed = index.observes_controls();
1711    let mut walked = Walked {
1712        observed,
1713        per_directory: BTreeMap::new(),
1714        by_ext: BTreeMap::new(),
1715        ignored_by_ext: BTreeMap::new(),
1716        rows: Vec::new(),
1717        members: Vec::new(),
1718        visible: BTreeSet::new(),
1719        visible_files: BTreeSet::new(),
1720        unknown_ignored: BTreeSet::new(),
1721    };
1722    // No entry of an index that read no rule can be shown to be ignored or not, so
1723    // `report_in` refuses a selection by ignored state before it reaches this walk.
1724    debug_assert!(
1725        observed || selection.ignored == IgnoredEntries::Include,
1726        "a selection by ignored state over an unobserving index is refused before the walk"
1727    );
1728
1729    // (id, path, post-order, covered by a selected ancestor)
1730    let mut stack = vec![(EntryId::ROOT, PathBuf::new(), false, false)];
1731    while let Some((id, path, expanded, covered)) = stack.pop() {
1732        if expanded {
1733            // Post-order: every child has finished, so fold their totals into this one.
1734            // `total` already carries this directory's own admitted files and admitted
1735            // directory children, both tallied in the pre-order pass below; what is left
1736            // is to add what each child subtree found deeper down.
1737            let mut total = walked.per_directory.remove(&id).unwrap_or_default();
1738            if let Some(children) = index.children_of(id) {
1739                for (_, child) in children {
1740                    if let Some(sub) = walked.per_directory.get(&child) {
1741                        let sub = *sub;
1742                        merge_summary(&mut total, &sub);
1743                        if walked.unknown_ignored.contains(&child) {
1744                            walked.unknown_ignored.insert(id);
1745                        }
1746                    }
1747                }
1748            }
1749            if total.files > 0 || total.dirs > 0 {
1750                walked.visible.insert(id);
1751            }
1752            walked.per_directory.insert(id, total);
1753            continue;
1754        }
1755
1756        stack.push((id, path.clone(), true, covered));
1757        let Some(children) = index.children_of(id) else {
1758            continue;
1759        };
1760        let children: Vec<(PathBuf, EntryId)> =
1761            children.map(|(name, child)| (path.join(name), child)).collect();
1762
1763        for (child_path, child) in children {
1764            let (Some(kind), Some(attrs)) = (index.kind_of(child), index.attrs_of(child)) else {
1765                continue;
1766            };
1767            // Bound once, and as an `OsStr`: the bucket has to be derived from the same
1768            // bytes the index interned from, or a name that is not valid UTF-8 would be
1769            // filed under one label by the fast tier and another by this one.
1770            let file_name = child_path.file_name().unwrap_or_default();
1771            let classification = index.ignored_classification_of(&child_path, child);
1772            let ignored = classification.unwrap_or(false);
1773            let classification_admitted =
1774                classification.is_some() || selection.ignored == IgnoredEntries::Include;
1775            let mut measured = *attrs;
1776            let subtree = directories.and_then(|values| values.get(&child)).copied();
1777            if let Some(subtree) = subtree {
1778                measured.size = subtree.bytes;
1779                measured.allocated = subtree.allocated;
1780                measured.mtime_ns = subtree.mtime_ns;
1781            }
1782            let (pruned, matches) = query_subtrees::with_candidate(
1783                &child_path,
1784                kind,
1785                measured,
1786                ignored,
1787                identity,
1788                |candidate| {
1789                    (query_subtrees::pruned(selection, &candidate), selection.admits(&candidate))
1790                },
1791            );
1792            if pruned {
1793                continue;
1794            }
1795            // An incomplete subtree's newest activity is a lower bound, not an age, so no
1796            // modification bound can be shown to hold for it: `before` could be disproved
1797            // by any unlisted descendant, and `since`, which a lower bound could prove, is
1798            // held to the same rule so that a row's presence under a time filter always
1799            // means the filter was decided on a complete measurement. A size bound still
1800            // matches, since a lower bound at or above the minimum proves the true size is.
1801            let matches = matches
1802                && classification_admitted
1803                && (selection.modified.is_unbounded()
1804                    || subtree.is_none_or(|subtree| subtree.complete));
1805            let row = FileRow {
1806                path: child_path.clone(),
1807                kind,
1808                bytes: measured.size,
1809                allocated: measured.allocated,
1810                mtime_ns: measured.mtime_ns,
1811                files: subtree.map(|subtree| subtree.files),
1812                dirs: subtree.map(|subtree| subtree.dirs),
1813                complete: subtree.map(|subtree| subtree.complete),
1814                age_ns: None,
1815                ignored: classification,
1816                sort_value: None,
1817                classification: None,
1818            };
1819            if matches {
1820                walked.rows.push(row.clone());
1821            }
1822            if matches || (covered && classification_admitted && selection.ignored.admits(ignored))
1823            {
1824                if classification.is_none() {
1825                    walked.unknown_ignored.insert(id);
1826                }
1827                if kind == EntryKind::File {
1828                    walked.members.push(row);
1829                    walked.visible_files.insert(child);
1830                } else if kind == EntryKind::Dir {
1831                    walked.visible.insert(child);
1832                }
1833
1834                if kind == EntryKind::File {
1835                    let own = walked.per_directory.entry(id).or_default();
1836                    own.files += 1;
1837                    own.bytes += attrs.size;
1838                    own.allocated += attrs.allocated;
1839                    own.newest_mtime_ns = Some(
1840                        own.newest_mtime_ns.map_or(attrs.mtime_ns, |seen| seen.max(attrs.mtime_ns)),
1841                    );
1842                    let bucket = crate::classify::ext_bucket(file_name);
1843                    if ignored {
1844                        own.ignored.get_or_insert_with(IgnoredTally::default).add(IgnoredTally {
1845                            files: 1,
1846                            dirs: 0,
1847                            bytes: attrs.size,
1848                            allocated: attrs.allocated,
1849                        });
1850                        let tally = walked.ignored_by_ext.entry(bucket.clone()).or_default();
1851                        tally.files += 1;
1852                        tally.bytes += attrs.size;
1853                        tally.allocated += attrs.allocated;
1854                    }
1855                    let tally = walked.by_ext.entry(bucket).or_default();
1856                    tally.files += 1;
1857                    tally.bytes += attrs.size;
1858                    tally.allocated += attrs.allocated;
1859                } else if kind == EntryKind::Dir {
1860                    // Tallied here, beside the file case, rather than in the post-order
1861                    // fold: the fold sees every directory the walk descended into, and
1862                    // counting there reported directories the selection had rejected.
1863                    // `--kind file` answered "6 files, 3 directories", and a summary
1864                    // disagreed with the files view over the very same query.
1865                    let own = walked.per_directory.entry(id).or_default();
1866                    own.dirs += 1;
1867                    if ignored {
1868                        own.ignored.get_or_insert_with(IgnoredTally::default).dirs += 1;
1869                    }
1870                }
1871            }
1872
1873            if kind == EntryKind::Dir {
1874                stack.push((child, child_path, false, covered || matches));
1875            }
1876        }
1877    }
1878
1879    walked
1880}
1881
1882/// Fold one subtree's filtered totals into another's.
1883fn merge_summary(into: &mut SummaryRow, from: &SummaryRow) {
1884    into.files += from.files;
1885    into.dirs += from.dirs;
1886    into.bytes += from.bytes;
1887    into.allocated += from.allocated;
1888    into.newest_mtime_ns = match (into.newest_mtime_ns, from.newest_mtime_ns) {
1889        (Some(left), Some(right)) => Some(left.max(right)),
1890        (left, right) => left.or(right),
1891    };
1892    if let Some(share) = from.ignored {
1893        into.ignored.get_or_insert_with(IgnoredTally::default).add(share);
1894    }
1895}
1896
1897/// Views that reconstruct every path into a [`FileRow`] when the selection is unfiltered.
1898fn needs_unfiltered_entry_rows(view: ViewSpec) -> bool {
1899    matches!(
1900        view,
1901        ViewSpec::Types
1902            | ViewSpec::Families
1903            | ViewSpec::Languages
1904            | ViewSpec::Code
1905            | ViewSpec::Documents
1906            | ViewSpec::Files
1907            | ViewSpec::Largest
1908            | ViewSpec::Recent
1909    )
1910}
1911
1912/// Content-grouping views that resolve the same current classification and analysis row.
1913fn needs_metric_resolution(view: ViewSpec) -> bool {
1914    matches!(view, ViewSpec::Types | ViewSpec::Families | ViewSpec::Languages | ViewSpec::Documents)
1915}
1916
1917/// The entry rows a view aggregates: the filtered walk, a shared unfiltered walk, or a
1918/// fresh [`every_entry`] when this is the only consumer.
1919fn entry_rows<'a>(
1920    index: &Index,
1921    walked: Option<&'a Walked>,
1922    unfiltered_rows: Option<&'a [FileRow]>,
1923) -> Cow<'a, [FileRow]> {
1924    match (walked, unfiltered_rows) {
1925        (Some(walked), _) => Cow::Borrowed(&walked.rows),
1926        (None, Some(rows)) => Cow::Borrowed(rows),
1927        (None, None) => Cow::Owned(every_entry(index)),
1928    }
1929}
1930
1931/// Build one view's section, using the pre-computed tier when the selection allows.
1932fn build_section(
1933    view: ViewSpec,
1934    index: &Index,
1935    query: &Query,
1936    content: AnalysisSet,
1937    walked: Option<&Walked>,
1938    unfiltered_rows: Option<&[FileRow]>,
1939    tree_measurements: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
1940) -> Section {
1941    if query.tree_for(view) {
1942        let (root, omissions) = tree_node(index, query, content, walked, tree_measurements);
1943        let limits = TreeDisplayLimits {
1944            depth: query.depth_for(view),
1945            min_share: query.min_share_for(),
1946            breadth: query.breadth_for(),
1947            rows: query.limit_for(view),
1948        };
1949        return Section::Tree { view, limits, root: root.map(Box::new), omissions };
1950    }
1951    match view {
1952        ViewSpec::Code => {
1953            Section::Code(Box::new(code_overview(index, query, content, walked, unfiltered_rows)))
1954        }
1955        ViewSpec::Summary => Section::Summary(match walked {
1956            None => unfiltered_summary(index, EntryId::ROOT, Path::new("")),
1957            Some(walked) => walked.summary_of(EntryId::ROOT),
1958        }),
1959        ViewSpec::Extensions => {
1960            let (rows, total, share_omitted) = extension_rows(index, query, walked);
1961            Section::Extensions { rows, total, share_omitted }
1962        }
1963        ViewSpec::Types | ViewSpec::Families | ViewSpec::Languages | ViewSpec::Documents => {
1964            Section::Metrics {
1965                view,
1966                summary: Box::new(metric_summary(
1967                    view,
1968                    index,
1969                    query,
1970                    content,
1971                    walked,
1972                    unfiltered_rows,
1973                )),
1974            }
1975        }
1976        ViewSpec::List
1977        | ViewSpec::Tree
1978        | ViewSpec::Files
1979        | ViewSpec::Largest
1980        | ViewSpec::Recent => {
1981            let (rows, total) = file_rows(view, index, query, content, walked, unfiltered_rows);
1982            Section::Files { view, rows, total }
1983        }
1984    }
1985}
1986
1987/// A summary row taken straight from pre-computed roll-up state, before any ignored share.
1988fn summary_from_scalars(rollup: RollUpScalars) -> SummaryRow {
1989    SummaryRow {
1990        files: rollup.files,
1991        dirs: rollup.dirs,
1992        bytes: rollup.bytes,
1993        allocated: rollup.allocated,
1994        ignored: None,
1995        newest_mtime_ns: (rollup.files > 0).then_some(rollup.newest_mtime_ns),
1996    }
1997}
1998
1999/// What each extension tally in `all` holds beyond the same extension in `unignored`.
2000fn ignored_by_extension(
2001    all: &BTreeMap<String, ExtTally>,
2002    unignored: &BTreeMap<String, ExtTally>,
2003) -> BTreeMap<String, ExtTally> {
2004    all.iter()
2005        .filter_map(|(extension, tally)| {
2006            let kept = unignored.get(extension).copied().unwrap_or_default();
2007            let ignored = ExtTally {
2008                files: tally.files.saturating_sub(kept.files),
2009                bytes: tally.bytes.saturating_sub(kept.bytes),
2010                allocated: tally.allocated.saturating_sub(kept.allocated),
2011            };
2012            (ignored.files > 0).then(|| (extension.clone(), ignored))
2013        })
2014        .collect()
2015}
2016
2017/// Rows for the types view.
2018fn extension_rows(
2019    index: &Index,
2020    query: &Query,
2021    walked: Option<&Walked>,
2022) -> (Vec<TypeRow>, usize, usize) {
2023    // Extension partitions cannot attribute an unknown member to one bucket from the
2024    // roll-up alone, so withhold their ignored subtotals until the scope is known.
2025    let observed = match walked {
2026        Some(walked) => walked.observed && !walked.unknown_ignored.contains(&EntryId::ROOT),
2027        None => {
2028            index.observes_controls() && index.ignored_classification_complete_below(Path::new(""))
2029        }
2030    };
2031    let (tallies, ignored): (BTreeMap<String, ExtTally>, BTreeMap<String, ExtTally>) = match walked
2032    {
2033        None => match index.partition_total() {
2034            Ok(partitions) => {
2035                let ignored =
2036                    ignored_by_extension(&partitions.all.by_ext, &partitions.unignored.by_ext);
2037                (partitions.all.by_ext, ignored)
2038            }
2039            // An index that observed no control state has no unignored partition to
2040            // subtract, and its rows carry no ignored share.
2041            Err(_not_observed) => (index.total().by_ext, BTreeMap::new()),
2042        },
2043        Some(walked) => (walked.by_ext.clone(), walked.ignored_by_ext.clone()),
2044    };
2045
2046    let mut rows: Vec<TypeRow> = tallies
2047        .into_iter()
2048        .map(|(extension, tally)| {
2049            let share = ignored.get(&extension).copied().unwrap_or_default();
2050            TypeRow {
2051                files: tally.files,
2052                bytes: tally.bytes,
2053                allocated: tally.allocated,
2054                ignored: observed.then_some(IgnoredTally {
2055                    files: share.files,
2056                    dirs: 0,
2057                    bytes: share.bytes,
2058                    allocated: share.allocated,
2059                }),
2060                extension,
2061            }
2062        })
2063        .collect();
2064
2065    let before_share = rows.len();
2066    if let Some(threshold) = &query.selection.min_share {
2067        let root = match walked {
2068            None => unfiltered_summary(index, EntryId::ROOT, Path::new("")),
2069            Some(walked) => walked.summary_of(EntryId::ROOT),
2070        };
2071        let denominator = match query.selection.size {
2072            SizeMetric::Apparent => root.bytes,
2073            SizeMetric::Allocated => root.allocated,
2074        };
2075        rows.retain(|row| {
2076            threshold.admits(
2077                match query.selection.size {
2078                    SizeMetric::Apparent => row.bytes,
2079                    SizeMetric::Allocated => row.allocated,
2080                },
2081                denominator,
2082            )
2083        });
2084    }
2085
2086    sort_rows(
2087        &mut rows,
2088        query,
2089        ViewSpec::Extensions,
2090        SortAccessors {
2091            size: |row: &TypeRow, metric| match metric {
2092                SizeMetric::Apparent => row.bytes,
2093                SizeMetric::Allocated => row.allocated,
2094            },
2095            count: |row: &TypeRow| row.files,
2096            mtime: |_: &TypeRow| None,
2097            name: |row: &TypeRow| row.extension.clone(),
2098            content_metric: |_: &TypeRow, _: &MetricDef| None,
2099        },
2100    );
2101    let total = truncate(&mut rows, query.limit_for(ViewSpec::Extensions));
2102    (rows, total, before_share - total)
2103}
2104
2105fn metric_summary(
2106    view: ViewSpec,
2107    index: &Index,
2108    query: &Query,
2109    content: AnalysisSet,
2110    walked: Option<&Walked>,
2111    unfiltered_rows: Option<&[FileRow]>,
2112) -> MetricSummary {
2113    let mut accumulator = MetricAccumulator::new(view);
2114    let files = walked.map_or_else(
2115        || entry_rows(index, None, unfiltered_rows),
2116        |walked| Cow::Borrowed(walked.members.as_slice()),
2117    );
2118    let wanted = index.content_identity(content);
2119    let held = index.content().and_then(|held| held.admit(&wanted));
2120    for file in files.iter().filter(|row| row.kind == EntryKind::File) {
2121        let cached = held.and_then(|content| content.file(&file.path));
2122        let classification = index.classify(&file.path);
2123        accumulator.push(content, file, cached, &classification);
2124    }
2125    accumulator.finish(query, content)
2126}
2127
2128/// Build several unfiltered metric sections in one file pass.
2129fn metric_summaries(
2130    views: &[ViewSpec],
2131    index: &Index,
2132    query: &Query,
2133    content: AnalysisSet,
2134    rows: &[FileRow],
2135) -> Vec<Option<MetricSummary>> {
2136    let mut accumulators = views
2137        .iter()
2138        .copied()
2139        .filter(|view| needs_metric_resolution(*view))
2140        .map(MetricAccumulator::new)
2141        .collect::<Vec<_>>();
2142    let wanted = index.content_identity(content);
2143    let held = index.content().and_then(|held| held.admit(&wanted));
2144    for file in rows.iter().filter(|row| row.kind == EntryKind::File) {
2145        let cached = held.and_then(|content| content.file(&file.path));
2146        let classification = index.classify(&file.path);
2147        for accumulator in &mut accumulators {
2148            accumulator.push(content, file, cached, &classification);
2149        }
2150    }
2151
2152    let mut finished =
2153        accumulators.into_iter().map(|accumulator| accumulator.finish(query, content));
2154    let summaries = views
2155        .iter()
2156        .map(|view| needs_metric_resolution(*view).then(|| finished.next().expect("metric view")))
2157        .collect();
2158    debug_assert!(finished.next().is_none());
2159    summaries
2160}
2161
2162struct MetricAccumulator {
2163    view: ViewSpec,
2164    group: MetricGroup,
2165    grouped: BTreeMap<String, MetricRow>,
2166}
2167
2168impl MetricAccumulator {
2169    fn new(view: ViewSpec) -> Self {
2170        debug_assert!(needs_metric_resolution(view));
2171        let group =
2172            if view == ViewSpec::Families { MetricGroup::Family } else { MetricGroup::Type };
2173        Self { view, group, grouped: BTreeMap::new() }
2174    }
2175
2176    fn push(
2177        &mut self,
2178        content: AnalysisSet,
2179        file: &FileRow,
2180        cached: Option<&FileAnalysis>,
2181        classification: &Classification,
2182    ) {
2183        let included = match self.view {
2184            ViewSpec::Languages => classification.family == ContentFamily::Code,
2185            ViewSpec::Documents => {
2186                matches!(classification.family, ContentFamily::Prose | ContentFamily::Markup)
2187            }
2188            ViewSpec::Types | ViewSpec::Families => true,
2189            ViewSpec::List
2190            | ViewSpec::Tree
2191            | ViewSpec::Extensions
2192            | ViewSpec::Code
2193            | ViewSpec::Files
2194            | ViewSpec::Largest
2195            | ViewSpec::Recent
2196            | ViewSpec::Summary => false,
2197        };
2198        if !included {
2199            return;
2200        }
2201        let id = match self.group {
2202            MetricGroup::Type => classification.file_type.as_str().to_string(),
2203            MetricGroup::Family => classification.family.as_str().to_string(),
2204        };
2205        let row = self.grouped.entry(id.clone()).or_insert_with(|| MetricRow {
2206            analysis: content,
2207            id,
2208            family: classification.family,
2209            files: 0,
2210            bytes: 0,
2211            allocated: 0,
2212            analyzed_files: 0,
2213            metrics: ReportMetricValues::for_analysis(content),
2214            logical_word_stats: LogicalWordStats::default(),
2215            visible_logical_word_stats: LogicalWordStats::default(),
2216            document_raw_words: 0,
2217            document_word_stats: LogicalWordStats::default(),
2218            document_metric_files: 0,
2219            coverage: BTreeMap::new(),
2220            lines_coverage: BTreeMap::new(),
2221            code_coverage: content.includes_code().then(BTreeMap::new),
2222            words_coverage: content.includes_words().then(BTreeMap::new),
2223            detection_sources: BTreeMap::new(),
2224            detection_confidence: BTreeMap::new(),
2225            generated_files: 0,
2226            vendored_files: 0,
2227            documentation_files: 0,
2228            share: MetricShare::default(),
2229        });
2230        row.files = row.files.saturating_add(1);
2231        row.bytes = row.bytes.saturating_add(file.bytes);
2232        row.allocated = row.allocated.saturating_add(file.allocated);
2233        let detection = cached.map_or(
2234            (classification.source, classification.confidence, classification.flags),
2235            |record| (record.detection.source, record.detection.confidence, record.detection.flags),
2236        );
2237        *row.detection_sources.entry(detection.0).or_default() += 1;
2238        *row.detection_confidence.entry(detection.1).or_default() += 1;
2239        row.generated_files = row.generated_files.saturating_add(u64::from(detection.2.generated));
2240        row.vendored_files = row.vendored_files.saturating_add(u64::from(detection.2.vendored));
2241        row.documentation_files =
2242            row.documentation_files.saturating_add(u64::from(detection.2.documentation));
2243        if let Some(record) = cached {
2244            *row.lines_coverage.entry(record.lines.coverage()).or_default() += 1;
2245            if let (Some(coverage), Some(outcome)) = (&mut row.code_coverage, record.code) {
2246                *coverage.entry(outcome.coverage()).or_default() += 1;
2247            }
2248            if let (Some(coverage), Some(outcome)) = (&mut row.words_coverage, record.words) {
2249                *coverage.entry(outcome.coverage()).or_default() += 1;
2250            }
2251            let selected = match self.view {
2252                ViewSpec::Languages if content.includes_code() => {
2253                    record.code.map(|outcome| outcome.coverage())
2254                }
2255                ViewSpec::Documents if content.includes_words() => {
2256                    record.words.map(|outcome| outcome.coverage())
2257                }
2258                _ => None,
2259            }
2260            .unwrap_or(record.lines.coverage());
2261            *row.coverage.entry(selected).or_default() += 1;
2262            if selected == CoverageReason::Analyzed {
2263                row.analyzed_files = row.analyzed_files.saturating_add(1);
2264            }
2265            if let Some(lines) = record.lines.value() {
2266                add_optional(&mut row.metrics.physical_lines, Some(lines.physical_lines));
2267                add_optional(&mut row.metrics.blank_lines, Some(lines.blank_lines));
2268                add_optional(&mut row.metrics.nonblank_lines, Some(lines.nonblank_lines));
2269                add_optional(&mut row.metrics.raw_words, Some(lines.raw_words));
2270                row.document_raw_words = row.document_raw_words.saturating_add(lines.raw_words);
2271            }
2272            if let Some(code_metrics) = record.code.and_then(crate::content::AnalyzerOutcome::value)
2273            {
2274                add_optional(&mut row.metrics.code_lines, Some(code_metrics.code_lines));
2275                add_optional(&mut row.metrics.comment_lines, Some(code_metrics.comment_lines));
2276                add_optional(
2277                    &mut row.metrics.code_blank_lines,
2278                    Some(code_metrics.code_blank_lines),
2279                );
2280            }
2281            if let Some(words) = record.words.and_then(crate::content::AnalyzerOutcome::value) {
2282                add_optional(&mut row.metrics.paragraphs, Some(words.paragraphs));
2283                add_optional(&mut row.metrics.visible_words, Some(words.visible_words));
2284                row.logical_word_stats.add_assign(words.logical_word_stats);
2285                row.visible_logical_word_stats.add_assign(words.visible_logical_word_stats);
2286                row.document_metric_files = row.document_metric_files.saturating_add(1);
2287                if classification.file_type.as_str() == "markdown" {
2288                    row.document_raw_words = row
2289                        .document_raw_words
2290                        .saturating_sub(record.lines.value().map_or(0, |lines| lines.raw_words));
2291                    row.document_raw_words =
2292                        row.document_raw_words.saturating_add(words.visible_words);
2293                    row.document_word_stats.add_assign(words.visible_logical_word_stats);
2294                } else {
2295                    row.document_word_stats.add_assign(words.logical_word_stats);
2296                }
2297            }
2298        }
2299    }
2300
2301    fn finish(self, query: &Query, content: AnalysisSet) -> MetricSummary {
2302        let Self { view, group, mut grouped } = self;
2303
2304        for row in grouped.values_mut() {
2305            row.finish_derived_metrics();
2306        }
2307
2308        let mut total = MetricRow {
2309            analysis: content,
2310            id: "total".to_string(),
2311            family: ContentFamily::Unknown,
2312            files: 0,
2313            bytes: 0,
2314            allocated: 0,
2315            analyzed_files: 0,
2316            metrics: ReportMetricValues::for_analysis(content),
2317            logical_word_stats: LogicalWordStats::default(),
2318            visible_logical_word_stats: LogicalWordStats::default(),
2319            document_raw_words: 0,
2320            document_word_stats: LogicalWordStats::default(),
2321            document_metric_files: 0,
2322            coverage: BTreeMap::new(),
2323            lines_coverage: BTreeMap::new(),
2324            code_coverage: content.includes_code().then(BTreeMap::new),
2325            words_coverage: content.includes_words().then(BTreeMap::new),
2326            detection_sources: BTreeMap::new(),
2327            detection_confidence: BTreeMap::new(),
2328            generated_files: 0,
2329            vendored_files: 0,
2330            documentation_files: 0,
2331            share: MetricShare::default(),
2332        };
2333        for row in grouped.values() {
2334            total.files = total.files.saturating_add(row.files);
2335            total.bytes = total.bytes.saturating_add(row.bytes);
2336            total.allocated = total.allocated.saturating_add(row.allocated);
2337            total.analyzed_files = total.analyzed_files.saturating_add(row.analyzed_files);
2338            total.metrics.add_assign(&row.metrics);
2339            total.logical_word_stats.add_assign(row.logical_word_stats);
2340            total.visible_logical_word_stats.add_assign(row.visible_logical_word_stats);
2341            total.document_raw_words =
2342                total.document_raw_words.saturating_add(row.document_raw_words);
2343            total.document_word_stats.add_assign(row.document_word_stats);
2344            total.document_metric_files =
2345                total.document_metric_files.saturating_add(row.document_metric_files);
2346            for (reason, count) in &row.coverage {
2347                *total.coverage.entry(*reason).or_default() += count;
2348            }
2349            merge_coverage(&mut total.lines_coverage, &row.lines_coverage);
2350            if let (Some(total), Some(row)) = (&mut total.code_coverage, &row.code_coverage) {
2351                merge_coverage(total, row);
2352            }
2353            if let (Some(total), Some(row)) = (&mut total.words_coverage, &row.words_coverage) {
2354                merge_coverage(total, row);
2355            }
2356            for (source, count) in &row.detection_sources {
2357                *total.detection_sources.entry(*source).or_default() += count;
2358            }
2359            for (confidence, count) in &row.detection_confidence {
2360                *total.detection_confidence.entry(*confidence).or_default() += count;
2361            }
2362            total.generated_files = total.generated_files.saturating_add(row.generated_files);
2363            total.vendored_files = total.vendored_files.saturating_add(row.vendored_files);
2364            total.documentation_files =
2365                total.documentation_files.saturating_add(row.documentation_files);
2366        }
2367        total.finish_derived_metrics();
2368        let byte_share_metric = match query.selection.size {
2369            SizeMetric::Apparent => ShareMetric::ApparentBytes,
2370            SizeMetric::Allocated => ShareMetric::AllocatedBytes,
2371        };
2372        let share_metric = match view {
2373            // The requested analyzers, not the stored ones: a share is a fact about what the
2374            // request asked to measure, and `validate_read` proved the index holds exactly it.
2375            ViewSpec::Languages if content.includes_code() => ShareMetric::CodeLines,
2376            ViewSpec::Documents if content.includes_words() => ShareMetric::DocumentWords,
2377            ViewSpec::Languages | ViewSpec::Documents if content.is_enabled() => {
2378                ShareMetric::RawWords
2379            }
2380            ViewSpec::Languages | ViewSpec::Documents | ViewSpec::Types | ViewSpec::Families => {
2381                byte_share_metric
2382            }
2383            ViewSpec::List
2384            | ViewSpec::Tree
2385            | ViewSpec::Extensions
2386            | ViewSpec::Code
2387            | ViewSpec::Files
2388            | ViewSpec::Largest
2389            | ViewSpec::Recent
2390            | ViewSpec::Summary => {
2391                unreachable!("only grouped views reach metric_summary")
2392            }
2393        };
2394        let denominator = share_value(&total, share_metric);
2395        total.share = MetricShare { numerator: denominator, denominator };
2396        let mut rows = grouped.into_values().collect::<Vec<_>>();
2397        for row in &mut rows {
2398            row.share = MetricShare { numerator: share_value(row, share_metric), denominator };
2399        }
2400        sort_rows(
2401            &mut rows,
2402            query,
2403            view,
2404            SortAccessors {
2405                size: |row: &MetricRow, metric| match view {
2406                    ViewSpec::Languages | ViewSpec::Documents => share_value(row, share_metric),
2407                    _ => match metric {
2408                        SizeMetric::Apparent => row.bytes,
2409                        SizeMetric::Allocated => row.allocated,
2410                    },
2411                },
2412                count: |row: &MetricRow| row.files,
2413                mtime: |_: &MetricRow| None,
2414                name: |row: &MetricRow| row.id.clone(),
2415                content_metric: MetricRow::metric_value,
2416            },
2417        );
2418        let before_share = rows.len();
2419        if let Some(threshold) = &query.selection.min_share {
2420            rows.retain(|row| threshold.admits(row.share.numerator, row.share.denominator));
2421        }
2422        let share_omitted = before_share - rows.len();
2423        let total_rows = truncate(&mut rows, query.limit_for(view));
2424        MetricSummary {
2425            group,
2426            total,
2427            rows,
2428            total_rows,
2429            share_omitted,
2430            share_metric,
2431            words_per_page: query.words_per_page.max(1),
2432        }
2433    }
2434}
2435
2436fn code_overview(
2437    index: &Index,
2438    query: &Query,
2439    content: AnalysisSet,
2440    walked: Option<&Walked>,
2441    unfiltered_rows: Option<&[FileRow]>,
2442) -> CodeOverview {
2443    #[derive(Default)]
2444    struct SortFacts {
2445        apparent: u64,
2446        allocated: u64,
2447        newest_mtime_ns: Option<i64>,
2448        metric: MetricAggregate,
2449    }
2450
2451    let files = walked.map_or_else(
2452        || entry_rows(index, None, unfiltered_rows),
2453        |walked| Cow::Borrowed(walked.members.as_slice()),
2454    );
2455    let held = index.content().and_then(|tier| tier.admit(&index.content_identity(content)));
2456    let split = query.selection.ignored == IgnoredEntries::Include && index.observes_controls();
2457    let sort_key = query.selection.sort.unwrap_or(SortKey::Metric("code_lines"));
2458    let sort_metric = match sort_key {
2459        SortKey::Metric(name) => Some(name),
2460        _ => None,
2461    };
2462    let mut overview = CodeOverview {
2463        population: query.selection.ignored,
2464        selected: CodeTally::default(),
2465        non_ignored: split.then(CodeTally::default),
2466        ignored: split.then(CodeTally::default),
2467        unknown: CodeTally::default(),
2468        unclassified_files: 0,
2469        analyzed_languages: 0,
2470        total_languages: 0,
2471        share_omitted: 0,
2472        languages: Vec::new(),
2473        share_metric: ShareMetric::CodeLines,
2474    };
2475    let mut grouped = BTreeMap::<String, CodeLanguageRow>::new();
2476    let mut sort_facts = BTreeMap::<String, SortFacts>::new();
2477    for file in files.iter().filter(|row| row.kind == EntryKind::File) {
2478        let record = held.and_then(|tier| tier.file(&file.path));
2479        let classification = record
2480            .map_or_else(|| index.classify(&file.path).into(), |record| record.detection.clone());
2481        if classification.family == ContentFamily::Unknown {
2482            overview.unclassified_files = overview.unclassified_files.saturating_add(1);
2483        }
2484        if classification.family != ContentFamily::Code {
2485            continue;
2486        }
2487        let language = classification.file_type.as_str().to_string();
2488        let facts = sort_facts.entry(language.clone()).or_default();
2489        facts.apparent = facts.apparent.saturating_add(file.bytes);
2490        facts.allocated = facts.allocated.saturating_add(file.allocated);
2491        facts.newest_mtime_ns =
2492            Some(facts.newest_mtime_ns.map_or(file.mtime_ns, |old| old.max(file.mtime_ns)));
2493        if let (Some(name), Some(record)) = (sort_metric, record) {
2494            if let Some(value) =
2495                measured_file_metric(record, classification.file_type.as_str(), name)
2496            {
2497                facts.metric.add(value);
2498            }
2499        }
2500        let row = grouped.entry(language.clone()).or_insert_with(|| CodeLanguageRow {
2501            language,
2502            selected: CodeTally::default(),
2503            non_ignored: split.then(CodeTally::default),
2504            ignored: split.then(CodeTally::default),
2505            unknown: CodeTally::default(),
2506            share: MetricShare::default(),
2507        });
2508        row.selected.add_file(record);
2509        overview.selected.add_file(record);
2510        match index.ignored_classification(&file.path) {
2511            Some(false) if split => {
2512                row.non_ignored.as_mut().expect("split initialized").add_file(record);
2513                overview.non_ignored.as_mut().expect("split initialized").add_file(record);
2514            }
2515            Some(true) if split => {
2516                row.ignored.as_mut().expect("split initialized").add_file(record);
2517                overview.ignored.as_mut().expect("split initialized").add_file(record);
2518            }
2519            None => {
2520                row.unknown.add_file(record);
2521                overview.unknown.add_file(record);
2522            }
2523            Some(_) => {}
2524        }
2525    }
2526    let denominator = overview.selected.metrics.code_lines;
2527    overview.analyzed_languages =
2528        grouped.values().filter(|row| row.selected.analyzed_files > 0).count() as u64;
2529    overview.languages = grouped.into_values().collect();
2530    for row in &mut overview.languages {
2531        row.share = MetricShare { numerator: row.selected.metrics.code_lines, denominator };
2532    }
2533    let normalized =
2534        matches!(sort_metric, Some("logical_words" | "visible_logical_words" | "document_words"));
2535    overview.languages.sort_by(|left, right| {
2536        let left_facts = &sort_facts[&left.language];
2537        let right_facts = &sort_facts[&right.language];
2538        let ordering = match sort_key {
2539            SortKey::Size => {
2540                let value = |facts: &SortFacts| match query.selection.size {
2541                    SizeMetric::Apparent => facts.apparent,
2542                    SizeMetric::Allocated => facts.allocated,
2543                };
2544                value(right_facts).cmp(&value(left_facts))
2545            }
2546            SortKey::Count => right.selected.source_files.cmp(&left.selected.source_files),
2547            SortKey::Mtime => right_facts.newest_mtime_ns.cmp(&left_facts.newest_mtime_ns),
2548            SortKey::Name => left.language.cmp(&right.language),
2549            SortKey::Metric(_) => {
2550                let left_value = left_facts.metric.value(normalized);
2551                let right_value = right_facts.metric.value(normalized);
2552                match (left_value, right_value) {
2553                    (Some(left), Some(right)) if query.selection.reverse => left.cmp(&right),
2554                    (Some(left), Some(right)) => right.cmp(&left),
2555                    (Some(_), None) => std::cmp::Ordering::Less,
2556                    (None, Some(_)) => std::cmp::Ordering::Greater,
2557                    (None, None) => std::cmp::Ordering::Equal,
2558                }
2559            }
2560        };
2561        let ordering = if query.selection.reverse && !matches!(sort_key, SortKey::Metric(_)) {
2562            ordering.reverse()
2563        } else {
2564            ordering
2565        };
2566        ordering.then_with(|| left.language.cmp(&right.language))
2567    });
2568    let before_share = overview.languages.len();
2569    if let Some(threshold) = &query.selection.min_share {
2570        overview.languages.retain(|row| threshold.admits(row.share.numerator, denominator));
2571    }
2572    overview.share_omitted = before_share - overview.languages.len();
2573    overview.total_languages = overview.languages.len();
2574    if let Some(limit) = query.limit_for(ViewSpec::Code).limit() {
2575        overview.languages.truncate(limit);
2576    }
2577    overview
2578}
2579
2580fn merge_coverage(total: &mut BTreeMap<CoverageReason, u64>, row: &BTreeMap<CoverageReason, u64>) {
2581    for (reason, count) in row {
2582        *total.entry(*reason).or_default() += count;
2583    }
2584}
2585
2586fn share_value(row: &MetricRow, metric: ShareMetric) -> u64 {
2587    match metric {
2588        ShareMetric::ApparentBytes => row.bytes,
2589        ShareMetric::AllocatedBytes => row.allocated,
2590        ShareMetric::CodeLines => row.metrics.code_lines.unwrap_or(0),
2591        ShareMetric::DocumentWords => document_words(row).unwrap_or(0),
2592        ShareMetric::RawWords => row.metrics.raw_words.unwrap_or(0),
2593    }
2594}
2595
2596/// Derive the selected document volume only after every sufficient statistic is added.
2597pub fn document_words(row: &MetricRow) -> Option<u64> {
2598    row.metrics.document_words
2599}
2600
2601/// Derived page inputs, absent unless the words unit was requested.
2602#[derive(Clone, Copy, PartialEq, Eq, Debug)]
2603pub struct Pages {
2604    /// Query-selected document words.
2605    pub words: u64,
2606    /// Words represented by one page.
2607    pub words_per_page: u64,
2608}
2609
2610/// Build page inputs only when document words were measured.
2611pub fn pages(row: &MetricRow, words_per_page: u64) -> Option<Pages> {
2612    document_words(row).map(|words| Pages { words, words_per_page: words_per_page.max(1) })
2613}
2614
2615/// Rows for the files view.
2616fn file_rows(
2617    view: ViewSpec,
2618    index: &Index,
2619    query: &Query,
2620    content: AnalysisSet,
2621    walked: Option<&Walked>,
2622    unfiltered_rows: Option<&[FileRow]>,
2623) -> (Vec<FileRow>, usize) {
2624    let mut rows = entry_rows(index, walked, unfiltered_rows).into_owned();
2625    if view.files_only() {
2626        rows.retain(|row| row.kind == EntryKind::File);
2627    }
2628    if let Some(SortKey::Metric(name)) = query.selection.sort {
2629        let sources = walked.map_or(rows.as_slice(), |walked| walked.members.as_slice());
2630        let values = metric_sort_values(index, content, sources, name);
2631        for row in &mut rows {
2632            row.sort_value = values.get(&row.path).copied();
2633        }
2634    }
2635
2636    sort_rows(
2637        &mut rows,
2638        query,
2639        view,
2640        SortAccessors {
2641            size: |row: &FileRow, metric| match metric {
2642                SizeMetric::Apparent => row.bytes,
2643                SizeMetric::Allocated => row.allocated,
2644            },
2645            count: |row: &FileRow| row.files.unwrap_or(1),
2646            mtime: |row: &FileRow| Some(row.mtime_ns),
2647            name: |row: &FileRow| row.path.to_string_lossy().into_owned(),
2648            content_metric: |row: &FileRow, _: &MetricDef| row.sort_value,
2649        },
2650    );
2651    let total = truncate(&mut rows, query.limit_for(view));
2652    let held = index.content().and_then(|tier| tier.admit(&index.content_identity(content)));
2653    for row in &mut rows {
2654        if row.kind == EntryKind::File {
2655            row.classification = Some(held.and_then(|tier| tier.file(&row.path)).map_or_else(
2656                || index.classify(&row.path).into(),
2657                |record| record.detection.clone(),
2658            ));
2659        }
2660    }
2661    (rows, total)
2662}
2663
2664#[derive(Clone, Copy)]
2665enum MetricMeasure {
2666    Additive(u64),
2667    Normalized(LogicalWordStats),
2668}
2669
2670#[derive(Default)]
2671struct MetricAggregate {
2672    additive: u64,
2673    normalized: LogicalWordStats,
2674    measured: bool,
2675}
2676
2677impl MetricAggregate {
2678    fn add(&mut self, measure: MetricMeasure) {
2679        self.measured = true;
2680        match measure {
2681            MetricMeasure::Additive(value) => {
2682                self.additive = self.additive.saturating_add(value);
2683            }
2684            MetricMeasure::Normalized(stats) => self.normalized.add_assign(stats),
2685        }
2686    }
2687
2688    fn value(&self, normalized: bool) -> Option<u64> {
2689        self.measured.then(
2690            || {
2691                if normalized { self.normalized.logical_words() } else { self.additive }
2692            },
2693        )
2694    }
2695}
2696
2697fn measured_file_metric(
2698    record: &crate::content::FileAnalysis,
2699    file_type: &str,
2700    metric_name: &str,
2701) -> Option<MetricMeasure> {
2702    let direct = |value| Some(MetricMeasure::Additive(value));
2703    match metric_name {
2704        "physical_lines" => direct(record.lines.value()?.physical_lines),
2705        "blank_lines" => direct(record.lines.value()?.blank_lines),
2706        "nonblank_lines" => direct(record.lines.value()?.nonblank_lines),
2707        "raw_words" => direct(record.lines.value()?.raw_words),
2708        "code_lines" => direct(record.code?.value()?.code_lines),
2709        "comment_lines" => direct(record.code?.value()?.comment_lines),
2710        "code_blank_lines" => direct(record.code?.value()?.code_blank_lines),
2711        "paragraphs" => direct(record.words?.value()?.paragraphs),
2712        "visible_words" => direct(record.words?.value()?.visible_words),
2713        "logical_words" => {
2714            Some(MetricMeasure::Normalized(record.words?.value()?.logical_word_stats))
2715        }
2716        "visible_logical_words" => {
2717            Some(MetricMeasure::Normalized(record.words?.value()?.visible_logical_word_stats))
2718        }
2719        "document_words" => {
2720            let words = record.words?.value()?;
2721            Some(MetricMeasure::Normalized(if file_type == "markdown" {
2722                words.visible_logical_word_stats
2723            } else {
2724                words.logical_word_stats
2725            }))
2726        }
2727        _ => None,
2728    }
2729}
2730
2731fn metric_sort_values(
2732    index: &Index,
2733    content: AnalysisSet,
2734    sources: &[FileRow],
2735    metric_name: &str,
2736) -> BTreeMap<PathBuf, u64> {
2737    let held = index.content().and_then(|tier| tier.admit(&index.content_identity(content)));
2738    let mut aggregate = BTreeMap::<PathBuf, MetricAggregate>::new();
2739    for file in sources.iter().filter(|row| row.kind == EntryKind::File) {
2740        let Some(record) = held.and_then(|tier| tier.file(&file.path)) else { continue };
2741        let classification = index.classify(&file.path);
2742        let Some(measure) =
2743            measured_file_metric(record, classification.file_type.as_str(), metric_name)
2744        else {
2745            continue;
2746        };
2747        let mut path = Some(file.path.as_path());
2748        while let Some(current) = path {
2749            if current.as_os_str().is_empty() {
2750                break;
2751            }
2752            aggregate.entry(current.to_path_buf()).or_default().add(measure);
2753            path = current.parent();
2754        }
2755    }
2756    let normalized =
2757        matches!(metric_name, "logical_words" | "visible_logical_words" | "document_words");
2758    aggregate
2759        .into_iter()
2760        .filter_map(|(path, values)| values.value(normalized).map(|value| (path, value)))
2761        .collect()
2762}
2763
2764/// Every entry in the index, for an unfiltered files view.
2765fn every_entry(index: &Index) -> Vec<FileRow> {
2766    let mut rows = Vec::new();
2767    let mut stack: Vec<(EntryId, PathBuf)> = vec![(EntryId::ROOT, PathBuf::new())];
2768    while let Some((id, path)) = stack.pop() {
2769        let Some(children) = index.children_of(id) else {
2770            continue;
2771        };
2772        let children: Vec<(PathBuf, EntryId)> =
2773            children.map(|(name, child)| (path.join(name), child)).collect();
2774        for (child_path, child) in children {
2775            let (Some(kind), Some(attrs)) = (index.kind_of(child), index.attrs_of(child)) else {
2776                continue;
2777            };
2778            rows.push(FileRow {
2779                path: child_path.clone(),
2780                kind,
2781                bytes: attrs.size,
2782                allocated: attrs.allocated,
2783                mtime_ns: attrs.mtime_ns,
2784                files: None,
2785                dirs: None,
2786                complete: None,
2787                age_ns: None,
2788                ignored: index.ignored_classification_of(&child_path, child),
2789                sort_value: None,
2790                classification: None,
2791            });
2792            if kind == EntryKind::Dir {
2793                stack.push((child, child_path));
2794            }
2795        }
2796    }
2797    rows
2798}
2799
2800/// The tree view's root node, expanded to the requested depth.
2801fn tree_node(
2802    index: &Index,
2803    query: &Query,
2804    content: AnalysisSet,
2805    walked: Option<&Walked>,
2806    tree_measurements: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
2807) -> (Option<TreeNode>, Vec<TreeOmission>) {
2808    let unfiltered = (walked.is_none() && matches!(query.selection.sort, Some(SortKey::Metric(_))))
2809        .then(|| every_entry(index));
2810    let metric_values = if let Some(SortKey::Metric(name)) = query.selection.sort {
2811        let sources = walked.map_or_else(
2812            || unfiltered.as_deref().unwrap_or(&[]),
2813            |walked| walked.members.as_slice(),
2814        );
2815        metric_sort_values(index, content, sources, name)
2816    } else {
2817        BTreeMap::new()
2818    };
2819    let root_summary = match walked {
2820        None => unfiltered_summary(index, EntryId::ROOT, Path::new("")),
2821        Some(walked) => walked.summary_of(EntryId::ROOT),
2822    };
2823
2824    let mut root = TreeNode {
2825        path: PathBuf::new(),
2826        name: ".".to_string(),
2827        kind: EntryKind::Dir,
2828        entry_ignored: index.ignored_classification_of(Path::new(""), EntryId::ROOT),
2829        bytes: root_summary.bytes,
2830        allocated: root_summary.allocated,
2831        files: root_summary.files,
2832        dirs: root_summary.dirs,
2833        ignored: root_summary.ignored,
2834        newest_mtime_ns: root_summary.newest_mtime_ns,
2835        children: Vec::new(),
2836        omissions: Vec::new(),
2837        truncated: false,
2838    };
2839    if query.limit_for(ViewSpec::Tree) == Bound::Limit(0) {
2840        let complete = index.state().coverage == crate::Coverage::Complete;
2841        let omitted = TreeOmission {
2842            reason: TreeOmissionReason::Rows,
2843            entries: 1,
2844            files: complete.then_some(root.files),
2845            bytes: complete.then_some(root.bytes),
2846            allocated: complete.then_some(root.allocated),
2847            ignored: complete.then_some(root.ignored).flatten().map(IgnoredSize::from_tally),
2848        };
2849        return (None, vec![omitted]);
2850    }
2851    expand(index, query, walked, &metric_values, tree_measurements, &mut root);
2852    if let Some(cap) = query.limit_for(ViewSpec::Tree).limit() {
2853        root = cap_tree_rows(root, cap, index.state().coverage == crate::Coverage::Complete);
2854    }
2855    (Some(root), Vec::new())
2856}
2857
2858fn record_omission(
2859    node: &mut TreeNode,
2860    reason: TreeOmissionReason,
2861    rows: &[(TreeNode, EntryId)],
2862    complete: bool,
2863) {
2864    if rows.is_empty() {
2865        return;
2866    }
2867    let bytes = complete
2868        .then(|| rows.iter().try_fold(0u64, |sum, (row, _)| sum.checked_add(row.bytes)))
2869        .flatten();
2870    let allocated = complete
2871        .then(|| rows.iter().try_fold(0u64, |sum, (row, _)| sum.checked_add(row.allocated)))
2872        .flatten();
2873    let files = complete
2874        .then(|| rows.iter().try_fold(0u64, |sum, (row, _)| sum.checked_add(row.files)))
2875        .flatten();
2876    let ignored = complete
2877        .then(|| {
2878            rows.iter().try_fold(IgnoredSize::default(), |sum, (row, _)| {
2879                sum.checked_add(IgnoredSize::from_tally(row.ignored?))
2880            })
2881        })
2882        .flatten();
2883    node.omissions.push(TreeOmission {
2884        reason,
2885        entries: rows.len(),
2886        files,
2887        bytes,
2888        allocated,
2889        ignored,
2890    });
2891    node.truncated = true;
2892}
2893
2894fn cap_tree_rows(root: TreeNode, cap: usize, complete: bool) -> TreeNode {
2895    struct Pending {
2896        node: TreeNode,
2897        parent: Option<usize>,
2898    }
2899    let mut pending = vec![Pending { node: root, parent: None }];
2900    let mut kept: Vec<Pending> = Vec::new();
2901    while let Some(mut item) = pending.pop() {
2902        if kept.len() == cap {
2903            let parent = item.parent.expect("root admitted by positive row cap");
2904            let omission = TreeOmission {
2905                reason: TreeOmissionReason::Rows,
2906                entries: 1,
2907                files: complete.then_some(item.node.files),
2908                bytes: complete.then_some(item.node.bytes),
2909                allocated: complete.then_some(item.node.allocated),
2910                ignored: complete
2911                    .then_some(item.node.ignored)
2912                    .flatten()
2913                    .map(IgnoredSize::from_tally),
2914            };
2915            let owner = &mut kept[parent].node;
2916            if let Some(existing) = owner
2917                .omissions
2918                .iter_mut()
2919                .find(|existing| existing.reason == TreeOmissionReason::Rows)
2920            {
2921                existing.entries += 1;
2922                existing.files =
2923                    existing.files.zip(omission.files).and_then(|(a, b)| a.checked_add(b));
2924                existing.bytes =
2925                    existing.bytes.zip(omission.bytes).and_then(|(a, b)| a.checked_add(b));
2926                existing.allocated =
2927                    existing.allocated.zip(omission.allocated).and_then(|(a, b)| a.checked_add(b));
2928                existing.ignored =
2929                    existing.ignored.zip(omission.ignored).and_then(|(a, b)| a.checked_add(b));
2930            } else {
2931                owner.omissions.push(omission);
2932            }
2933            owner.truncated = true;
2934            continue;
2935        }
2936        let children = std::mem::take(&mut item.node.children);
2937        let current = kept.len();
2938        kept.push(item);
2939        for child in children.into_iter().rev() {
2940            pending.push(Pending { node: child, parent: Some(current) });
2941        }
2942    }
2943    for position in (1..kept.len()).rev() {
2944        let child = kept.remove(position);
2945        kept[child.parent.expect("only root lacks parent")].node.children.insert(0, child.node);
2946    }
2947    kept.pop().expect("positive cap admits root").node
2948}
2949
2950/// Attach a node's children, honoring the depth and per-directory limit bounds.
2951///
2952/// Iterative rather than recursive: this engine indexes trees deep enough that recursive
2953/// expansion would exhaust the stack, and a report that panics on a deep tree fails
2954/// exactly where the tool is most useful. Nodes are built flat with parent links in
2955/// pre-order, then folded together from the leaves up.
2956fn expand(
2957    index: &Index,
2958    query: &Query,
2959    walked: Option<&Walked>,
2960    metric_values: &BTreeMap<PathBuf, u64>,
2961    tree_measurements: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
2962    node: &mut TreeNode,
2963) {
2964    /// One node awaiting its children.
2965    struct Pending {
2966        node: TreeNode,
2967        id: EntryId,
2968        depth: usize,
2969        parent: Option<usize>,
2970    }
2971
2972    let mut built = vec![Pending {
2973        // Only identity and bounds matter while expanding; the caller keeps the
2974        // populated root and receives its children back at the end.
2975        node: TreeNode {
2976            path: node.path.clone(),
2977            name: node.name.clone(),
2978            kind: node.kind,
2979            entry_ignored: node.entry_ignored,
2980            bytes: node.bytes,
2981            allocated: node.allocated,
2982            files: node.files,
2983            dirs: node.dirs,
2984            ignored: node.ignored,
2985            newest_mtime_ns: node.newest_mtime_ns,
2986            children: Vec::new(),
2987            omissions: Vec::new(),
2988            truncated: false,
2989        },
2990        id: EntryId::ROOT,
2991        depth: 0,
2992        parent: None,
2993    }];
2994
2995    let threshold = query.min_share_for();
2996    let grand = match query.selection.size {
2997        SizeMetric::Apparent => node.bytes,
2998        SizeMetric::Allocated => node.allocated,
2999    };
3000    let complete = index.state().coverage == crate::Coverage::Complete;
3001    let mut cursor = 0;
3002    while cursor < built.len() {
3003        let (id, depth) = (built[cursor].id, built[cursor].depth);
3004        let path = built[cursor].node.path.clone();
3005
3006        let mut rows = child_rows(index, query, walked, metric_values, id, &path);
3007        let mut below_share = Vec::new();
3008        rows.retain(|(row, child)| {
3009            let value = match query.selection.size {
3010                SizeMetric::Apparent => row.bytes,
3011                SizeMetric::Allocated => row.allocated,
3012            };
3013            // A complete child below a partial root's observed total is also below
3014            // the true (at least as large) total. Only an incomplete child's own
3015            // unknown contents prevent that proof; unrelated scan errors do not.
3016            let child_complete = row.kind == EntryKind::File
3017                || tree_measurements
3018                    .is_none_or(|values| values.get(child).is_some_and(|subtree| subtree.complete));
3019            let eligible = !child_complete || threshold.admits(value, grand);
3020            if !eligible {
3021                below_share.push((row.clone(), *child));
3022            }
3023            eligible
3024        });
3025        record_omission(&mut built[cursor].node, TreeOmissionReason::Share, &below_share, true);
3026        if !query.depth_for(ViewSpec::Tree).admits(depth) {
3027            record_omission(&mut built[cursor].node, TreeOmissionReason::Depth, &rows, complete);
3028            cursor += 1;
3029            continue;
3030        }
3031        if let Some(cap) = query.breadth_for().limit() {
3032            let hidden = rows.split_off(cap.min(rows.len()));
3033            record_omission(
3034                &mut built[cursor].node,
3035                TreeOmissionReason::Breadth,
3036                &hidden,
3037                complete,
3038            );
3039        }
3040
3041        for (child_node, child_id) in rows {
3042            built.push(Pending {
3043                node: child_node,
3044                id: child_id,
3045                depth: depth + 1,
3046                parent: Some(cursor),
3047            });
3048        }
3049        cursor += 1;
3050    }
3051
3052    // Fold from the end: every parent index is smaller than its child's, so removing the
3053    // last element never disturbs an index still to be used.
3054    for position in (1..built.len()).rev() {
3055        let child = built.remove(position);
3056        let parent = child.parent.expect("only the root has no parent");
3057        built[parent].node.children.insert(0, child.node);
3058    }
3059
3060    let mut root = built.pop().expect("the root is always present");
3061    node.children = std::mem::take(&mut root.node.children);
3062    node.omissions = std::mem::take(&mut root.node.omissions);
3063    node.truncated = root.node.truncated;
3064}
3065
3066/// The directory children of one node, shaped and sorted but not yet expanded.
3067fn child_rows(
3068    index: &Index,
3069    query: &Query,
3070    walked: Option<&Walked>,
3071    metric_values: &BTreeMap<PathBuf, u64>,
3072    id: EntryId,
3073    path: &Path,
3074) -> Vec<(TreeNode, EntryId)> {
3075    let Some(children) = index.children_of(id) else {
3076        return Vec::new();
3077    };
3078    let children: Vec<(PathBuf, EntryId)> =
3079        children.map(|(name, child)| (path.join(name), child)).collect();
3080
3081    let mut rows: Vec<(TreeNode, EntryId)> = Vec::new();
3082    for (child_path, child) in children {
3083        let Some(kind) = index.kind_of(child) else {
3084            continue;
3085        };
3086        if !matches!(kind, EntryKind::Dir | EntryKind::File) {
3087            continue;
3088        }
3089        if walked.is_some_and(|walked| match kind {
3090            EntryKind::Dir => !walked.visible.contains(&child),
3091            EntryKind::File => !walked.visible_files.contains(&child),
3092            EntryKind::Symlink | EntryKind::Other => true,
3093        }) {
3094            continue;
3095        }
3096        let entry_ignored = index.ignored_classification_of(&child_path, child);
3097        let summary = if kind == EntryKind::File {
3098            let attrs = index.attrs_of(child).expect("live child has attributes");
3099            let ignored = entry_ignored.map(|ignored| {
3100                if ignored {
3101                    IgnoredTally {
3102                        files: 1,
3103                        dirs: 0,
3104                        bytes: attrs.size,
3105                        allocated: attrs.allocated,
3106                    }
3107                } else {
3108                    IgnoredTally::default()
3109                }
3110            });
3111            SummaryRow {
3112                files: 1,
3113                dirs: 0,
3114                bytes: attrs.size,
3115                allocated: attrs.allocated,
3116                ignored,
3117                newest_mtime_ns: Some(attrs.mtime_ns),
3118            }
3119        } else {
3120            match walked {
3121                None => unfiltered_summary(index, child, &child_path),
3122                Some(walked) => walked.summary_of(child),
3123            }
3124        };
3125        let name = child_path
3126            .file_name()
3127            .map(|name| name.to_string_lossy().into_owned())
3128            .unwrap_or_default();
3129        rows.push((
3130            TreeNode {
3131                path: child_path,
3132                name,
3133                kind,
3134                entry_ignored,
3135                bytes: summary.bytes,
3136                allocated: summary.allocated,
3137                files: summary.files,
3138                dirs: summary.dirs,
3139                ignored: summary.ignored,
3140                newest_mtime_ns: summary.newest_mtime_ns,
3141                children: Vec::new(),
3142                omissions: Vec::new(),
3143                truncated: false,
3144            },
3145            child,
3146        ));
3147    }
3148
3149    sort_rows_by(
3150        &mut rows,
3151        query,
3152        ViewSpec::Tree,
3153        SortAccessors {
3154            size: |(row, _): &(TreeNode, EntryId), metric| match metric {
3155                SizeMetric::Apparent => row.bytes,
3156                SizeMetric::Allocated => row.allocated,
3157            },
3158            count: |(row, _): &(TreeNode, EntryId)| row.files,
3159            mtime: |(row, _): &(TreeNode, EntryId)| row.newest_mtime_ns,
3160            name: |(row, _): &(TreeNode, EntryId)| row.name.clone(),
3161            content_metric: |(row, _): &(TreeNode, EntryId), _: &MetricDef| {
3162                metric_values.get(&row.path).copied()
3163            },
3164        },
3165    );
3166    rows
3167}
3168
3169/// Trim a row list to the configured limit.
3170fn truncate<T>(rows: &mut Vec<T>, limit: Bound) -> usize {
3171    let total = rows.len();
3172    if let Some(limit) = limit.limit() {
3173        rows.truncate(limit);
3174    }
3175    total
3176}
3177
3178/// Sort rows by the effective key for a view.
3179struct SortAccessors<S, C, M, N, V> {
3180    size: S,
3181    count: C,
3182    mtime: M,
3183    name: N,
3184    content_metric: V,
3185}
3186
3187fn sort_rows<T>(
3188    rows: &mut [T],
3189    query: &Query,
3190    view: ViewSpec,
3191    accessors: SortAccessors<
3192        impl Fn(&T, SizeMetric) -> u64,
3193        impl Fn(&T) -> u64,
3194        impl Fn(&T) -> Option<i64>,
3195        impl Fn(&T) -> String,
3196        impl Fn(&T, &MetricDef) -> Option<u64>,
3197    >,
3198) {
3199    sort_rows_by(rows, query, view, accessors);
3200}
3201
3202/// Sort rows by the effective key, with a stable name tiebreak.
3203fn sort_rows_by<T>(
3204    rows: &mut [T],
3205    query: &Query,
3206    view: ViewSpec,
3207    accessors: SortAccessors<
3208        impl Fn(&T, SizeMetric) -> u64,
3209        impl Fn(&T) -> u64,
3210        impl Fn(&T) -> Option<i64>,
3211        impl Fn(&T) -> String,
3212        impl Fn(&T, &MetricDef) -> Option<u64>,
3213    >,
3214) {
3215    let SortAccessors { size, count, mtime, name, content_metric } = accessors;
3216    let key = query.selection.sort.unwrap_or_else(|| view.default_sort());
3217    let metric = query.selection.size;
3218
3219    rows.sort_by(|left, right| {
3220        let ordering = match key {
3221            // Size, count, and recency read most-first: the interesting end is the top.
3222            SortKey::Size => size(right, metric).cmp(&size(left, metric)),
3223            SortKey::Count => count(right).cmp(&count(left)),
3224            SortKey::Mtime => mtime(right).cmp(&mtime(left)),
3225            SortKey::Name => name(left).cmp(&name(right)),
3226            SortKey::Metric(metric_name) => {
3227                let definition =
3228                    crate::content::METRICS.iter().find(|entry| entry.name == metric_name);
3229                let left_value = definition.and_then(|definition| content_metric(left, definition));
3230                let right_value =
3231                    definition.and_then(|definition| content_metric(right, definition));
3232                match (left_value, right_value) {
3233                    (Some(left), Some(right)) if query.selection.reverse => left.cmp(&right),
3234                    (Some(left), Some(right)) => right.cmp(&left),
3235                    (Some(_), None) => std::cmp::Ordering::Less,
3236                    (None, Some(_)) => std::cmp::Ordering::Greater,
3237                    (None, None) => std::cmp::Ordering::Equal,
3238                }
3239            }
3240        };
3241        // A name tiebreak keeps equal rows in a deterministic order, which is what makes
3242        // the goldens stable across runs and platforms.
3243        ordering.then_with(|| name(left).cmp(&name(right)))
3244    });
3245
3246    if query.selection.reverse && !matches!(key, SortKey::Metric(_)) {
3247        rows.reverse();
3248    }
3249}
3250
3251#[cfg(test)]
3252mod tests {
3253    use super::*;
3254    use crate::engine_contract::{Attrs, Observation, Op};
3255    use crate::query::query_glob::Pattern;
3256    use crate::query::query_selection::ModifiedWindow;
3257    use std::fs;
3258    use std::time::{Duration, UNIX_EPOCH};
3259
3260    fn attrs(size: u64, mtime_ns: i64) -> Attrs {
3261        Attrs {
3262            size,
3263            allocated: size.div_ceil(512) * 512,
3264            mtime_ns,
3265            ctime_ns: mtime_ns,
3266            inode: size.wrapping_mul(31).wrapping_add(mtime_ns.unsigned_abs()),
3267            dev: 1,
3268        }
3269    }
3270
3271    fn upsert(path: &str, kind: EntryKind, attrs: Attrs) -> Op {
3272        Op::Upsert { path: PathBuf::from(path), kind, attrs }
3273    }
3274
3275    /// A tree with two top-level directories, a nested level, and three extensions.
3276    fn sample() -> Index {
3277        let mut index = Index::new("/root");
3278        index
3279            .apply(&Observation::new(vec![
3280                upsert("src", EntryKind::Dir, Attrs::default()),
3281                upsert("src/main.rs", EntryKind::File, attrs(100, 10)),
3282                upsert("src/lib.rs", EntryKind::File, attrs(200, 20)),
3283                upsert("src/deep", EntryKind::Dir, Attrs::default()),
3284                upsert("src/deep/nested.rs", EntryKind::File, attrs(50, 40)),
3285                upsert("docs", EntryKind::Dir, Attrs::default()),
3286                upsert("docs/guide.md", EntryKind::File, attrs(300, 30)),
3287                upsert("notes.txt", EntryKind::File, attrs(7, 5)),
3288            ]))
3289            .expect("apply");
3290        index
3291    }
3292
3293    #[test]
3294    fn ages_use_one_signed_reference_and_unrepresentable_clocks_are_unknown() {
3295        let mut index = sample();
3296        index.apply_ok(&Observation::new(vec![
3297            upsert("past", EntryKind::File, attrs(1, -10)),
3298            upsert("future", EntryKind::File, attrs(1, i64::MAX)),
3299        ]));
3300        let query = query(
3301            &[ViewSpec::Files],
3302            Selection { include: vec![pattern("past"), pattern("future")], ..Selection::default() },
3303        );
3304        let mut request = Request::new(Basis::held_by(&index), query, UNIX_EPOCH);
3305        let answer = report(&index, &request, UNIX_EPOCH).expect("report");
3306        let rows = files_of(&answer);
3307        assert_eq!(answer.age_reference_ns, Some(0));
3308        assert_eq!(
3309            rows.iter().map(|r| r.age_ns).collect::<Vec<_>>(),
3310            [Some(-i128::from(i64::MAX)), Some(10)]
3311        );
3312        request.now = UNIX_EPOCH + Duration::from_secs(10_000_000_000);
3313        let answer = report(&index, &request, UNIX_EPOCH)
3314            .expect("out-of-range reference is representable as unknown age");
3315        assert_eq!(answer.age_reference_ns, None);
3316        assert!(files_of(&answer).iter().all(|row| row.age_ns.is_none()));
3317    }
3318
3319    #[test]
3320    fn matching_a_directory_selects_its_subtree_once() {
3321        let index = sample();
3322        let selection = Selection {
3323            include: vec![pattern("src"), pattern("deep")],
3324            kinds: vec![EntryKind::Dir],
3325            size: SizeMetric::Apparent,
3326            min_size: Some(40),
3327            ..Selection::default()
3328        };
3329        let report = run(
3330            &index,
3331            &query(&[ViewSpec::Files, ViewSpec::Summary, ViewSpec::Extensions], selection),
3332        );
3333        let rows = files_of(&report);
3334        assert_eq!(rows.len(), 2);
3335        assert_eq!((rows[0].bytes, rows[0].mtime_ns), (350, 40));
3336        let Section::Summary(summary) = &report.sections[1] else { panic!("summary") };
3337        assert_eq!((summary.files, summary.dirs, summary.bytes), (3, 2, 350));
3338        let Section::Extensions { rows, .. } = &report.sections[2] else { panic!("extensions") };
3339        assert_eq!((rows[0].files, rows[0].bytes), (3, 350));
3340    }
3341
3342    #[test]
3343    fn subtree_predicates_include_directory_and_symlink_activity_but_only_file_bytes() {
3344        let mut index = sample();
3345        index
3346            .apply(&Observation::new(vec![
3347                upsert("src", EntryKind::Dir, attrs(9999, 45)),
3348                upsert("src/empty", EntryKind::Dir, attrs(8888, 60)),
3349                upsert("src/link", EntryKind::Symlink, attrs(7777, 70)),
3350                upsert("empty", EntryKind::Dir, attrs(6666, -10)),
3351            ]))
3352            .expect("apply");
3353        let base = Selection {
3354            include: vec![pattern("src"), pattern("empty")],
3355            size: SizeMetric::Apparent,
3356            ..Selection::default()
3357        };
3358        // A one-shot report matches native names, so the nested spelling is joined rather
3359        // than written with a literal separator: `src/empty` holds only on Unix.
3360        let nested = PathBuf::from("src").join("empty").to_string_lossy().into_owned();
3361        let rows = files_of(&run(&index, &query(&[ViewSpec::Files], base.clone())));
3362        assert_eq!(
3363            rows.iter()
3364                .map(|r| (r.path.to_string_lossy().into_owned(), r.bytes, r.mtime_ns))
3365                .collect::<Vec<_>>(),
3366            [("empty".into(), 0, -10), ("src".into(), 350, 70), (nested.clone(), 0, 60)]
3367        );
3368        for (before, since, expected) in [
3369            (70, 0, vec![nested.clone()]),
3370            (71, 70, vec!["src".to_owned()]),
3371            (0, -10, vec!["empty".to_owned()]),
3372        ] {
3373            let selection = Selection {
3374                modified: ModifiedWindow { before: Some(before), since: Some(since) },
3375                ..base.clone()
3376            };
3377            let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
3378            assert_eq!(
3379                rows.iter().map(|r| r.path.to_string_lossy().into_owned()).collect::<Vec<_>>(),
3380                expected
3381            );
3382        }
3383        for (size, minimum, expected) in [
3384            (SizeMetric::Apparent, 350, 1),
3385            (SizeMetric::Apparent, 351, 0),
3386            (SizeMetric::Allocated, 1536, 1),
3387            (SizeMetric::Allocated, 1537, 0),
3388        ] {
3389            let selection = Selection { size, min_size: Some(minimum), ..base.clone() };
3390            assert_eq!(
3391                files_of(&run(&index, &query(&[ViewSpec::Files], selection))).len(),
3392                expected
3393            );
3394        }
3395    }
3396
3397    #[test]
3398    fn exclusions_apply_before_subtree_bounds_and_selected_ancestor_coverage() {
3399        let index = classified_sample();
3400        for (ignored, exclude, bytes, newest) in [
3401            (IgnoredEntries::Include, vec![], 325, 70),
3402            (IgnoredEntries::Exclude, vec![], 300, 20),
3403            (IgnoredEntries::Include, vec![pattern("*.log")], 300, 20),
3404            (IgnoredEntries::Include, vec![pattern("lib.rs")], 125, 70),
3405        ] {
3406            let selection = Selection {
3407                include: vec![pattern("src")],
3408                kinds: vec![EntryKind::Dir],
3409                ignored,
3410                exclude,
3411                size: SizeMetric::Apparent,
3412                ..Selection::default()
3413            };
3414            let report = run(
3415                &index,
3416                &query(&[ViewSpec::Files, ViewSpec::Summary, ViewSpec::Types], selection),
3417            );
3418            let row = &files_of(&report)[0];
3419            assert_eq!((row.bytes, row.mtime_ns), (bytes, newest));
3420            let Section::Summary(summary) = &report.sections[1] else { panic!("summary") };
3421            assert_eq!(summary.bytes, bytes);
3422            let Section::Metrics { summary, .. } = &report.sections[2] else { panic!("types") };
3423            assert_eq!(summary.rows.iter().map(|r| r.bytes).sum::<u64>(), bytes);
3424        }
3425        let selection = Selection {
3426            include: vec![pattern("build")],
3427            exclude: vec![pattern("cache")],
3428            kinds: vec![EntryKind::Dir],
3429            ..Selection::default()
3430        };
3431        let report = run(&index, &query(&[ViewSpec::Files, ViewSpec::Summary], selection));
3432        assert_eq!(files_of(&report)[0].bytes, 0);
3433        let Section::Summary(summary) = &report.sections[1] else { panic!("summary") };
3434        assert_eq!((summary.files, summary.dirs, summary.bytes), (0, 1, 0));
3435        let only = Selection {
3436            include: vec![pattern("cache")],
3437            kinds: vec![EntryKind::Dir],
3438            ignored: IgnoredEntries::Only,
3439            ..Selection::default()
3440        };
3441        assert_eq!(files_of(&run(&index, &query(&[ViewSpec::Files], only)))[0].bytes, 1000);
3442    }
3443
3444    /// A directory whose subtree was not listed in full says so, and its lower-bound
3445    /// activity is not an age: it matches no modification bound, in either direction,
3446    /// while a size bound still holds on the lower bound it can prove.
3447    #[test]
3448    fn an_incomplete_subtree_reports_lower_bounds_and_matches_no_time_bound() {
3449        // `--scan-depth 2`: `env/lib` sits at the boundary, retained and never listed, so
3450        // `env` is incomplete; `docs` holds only files at that depth and is complete.
3451        let mut index = Index::new_with_scope(
3452            "/root",
3453            crate::ScanScope { max_depth: Some(2), ..crate::ScanScope::default() },
3454        );
3455        index.apply_ok(&Observation::new(vec![
3456            upsert("env", EntryKind::Dir, attrs(0, 5)),
3457            upsert("env/lib", EntryKind::Dir, attrs(0, 7)),
3458            upsert("env/a.bin", EntryKind::File, attrs(100, 40)),
3459            upsert("docs", EntryKind::Dir, attrs(0, 5)),
3460            upsert("docs/guide.md", EntryKind::File, attrs(30, 50)),
3461        ]));
3462        let directories = |selection: Selection| {
3463            let selection =
3464                Selection { kinds: vec![EntryKind::Dir], size: SizeMetric::Apparent, ..selection };
3465            files_of(&run(&index, &flat(selection)))
3466                .into_iter()
3467                .map(|row| {
3468                    (row.path.to_string_lossy().into_owned(), row.complete, row.bytes, row.age_ns)
3469                })
3470                .collect::<Vec<_>>()
3471        };
3472        // Joined natively, as the report joins it: Windows spells this row `env\lib`.
3473        let env_lib = Path::new("env").join("lib").to_string_lossy().into_owned();
3474        assert_eq!(
3475            directories(Selection::default()),
3476            vec![
3477                ("env".to_string(), Some(false), 100, None),
3478                ("docs".to_string(), Some(true), 30, Some(-50)),
3479                (env_lib, Some(false), 0, None),
3480            ],
3481            "sizes are lower bounds and the age is unknown below the boundary"
3482        );
3483        for modified in [
3484            ModifiedWindow { since: None, before: Some(100) },
3485            ModifiedWindow { since: Some(0), before: None },
3486        ] {
3487            assert_eq!(
3488                directories(Selection { modified, ..Selection::default() }),
3489                vec![("docs".to_string(), Some(true), 30, Some(-50))],
3490                "an unknown age satisfies no bound, not even one its lower bound would prove"
3491            );
3492        }
3493        assert_eq!(
3494            directories(Selection { min_size: Some(100), ..Selection::default() }),
3495            vec![("env".to_string(), Some(false), 100, None)],
3496            "a lower bound at or above the minimum proves the true size is too"
3497        );
3498        assert!(directories(Selection { min_size: Some(101), ..Selection::default() }).is_empty());
3499        // A regular file has no subtree to be incomplete, and its own age stands.
3500        let files = files_of(&run(
3501            &index,
3502            &flat(Selection {
3503                kinds: vec![EntryKind::File],
3504                modified: ModifiedWindow { since: Some(45), before: None },
3505                ..Selection::default()
3506            }),
3507        ));
3508        assert_eq!(files.len(), 1);
3509        assert_eq!((files[0].complete, files[0].age_ns), (None, Some(-50)));
3510    }
3511
3512    /// A legacy unscoped partial marker carries no authoritative child-list evidence,
3513    /// so no directory row can claim completeness. Scoped scan failures preserve healthy
3514    /// siblings, as the public partial-directory integration test proves.
3515    #[test]
3516    fn an_unscoped_partial_marker_marks_every_directory_row_incomplete() {
3517        let directories = |index: &Index| {
3518            files_of(&run(
3519                index,
3520                &flat(Selection { kinds: vec![EntryKind::Dir], ..Selection::default() }),
3521            ))
3522        };
3523        let mut partial = sample();
3524        partial.set_initial_freshness(false);
3525        let rows = directories(&partial);
3526        assert_eq!(rows.len(), 3);
3527        assert!(rows.iter().all(|row| row.complete == Some(false) && row.age_ns.is_none()));
3528        let mut complete = sample();
3529        complete.set_initial_freshness(true);
3530        let rows = directories(&complete);
3531        assert_eq!(rows.len(), 3);
3532        assert!(rows.iter().all(|row| row.complete == Some(true) && row.age_ns.is_some()));
3533    }
3534
3535    /// While an opened root is discovering, a directory is complete exactly when
3536    /// discovery has listed it, so a report served mid-discovery marks the rest.
3537    #[test]
3538    fn an_opened_root_marks_a_directory_complete_only_once_discovery_listed_it() {
3539        let handle = crate::index::IndexHandle::new(Index::new("/root"));
3540        handle
3541            .transition_discovery(crate::index::DiscoveryTransition::Begin)
3542            .expect("begin discovery");
3543        handle
3544            .apply(&Observation::new(vec![
3545                upsert("known", EntryKind::Dir, attrs(0, 5)),
3546                upsert("pending", EntryKind::Dir, attrs(0, 5)),
3547            ]))
3548            .expect("seed directories");
3549        handle
3550            .apply_discovery(
3551                &Observation::new(Vec::new()),
3552                crate::index::DiscoveryCommit {
3553                    directory_complete: Some(PathBuf::from("known")),
3554                    transition: None,
3555                },
3556            )
3557            .expect("list one directory");
3558        let completeness = handle
3559            .read_with(|index| {
3560                files_of(&run(
3561                    index,
3562                    &flat(Selection { kinds: vec![EntryKind::Dir], ..Selection::default() }),
3563                ))
3564                .into_iter()
3565                .map(|row| (row.path.to_string_lossy().into_owned(), row.complete))
3566                .collect::<BTreeMap<_, _>>()
3567            })
3568            .expect("read");
3569        assert_eq!(
3570            completeness,
3571            BTreeMap::from([
3572                ("known".to_string(), Some(true)),
3573                ("pending".to_string(), Some(false))
3574            ])
3575        );
3576    }
3577
3578    #[test]
3579    fn a_filtered_tree_keeps_empty_matches_and_only_folds_visible_directories() {
3580        let mut index = sample();
3581        index
3582            .apply(&Observation::new(vec![upsert("src/empty", EntryKind::Dir, Attrs::default())]))
3583            .expect("apply");
3584        let selection = Selection {
3585            include: vec![pattern("empty")],
3586            depth: Some(Bound::All),
3587            min_share: Some(ShareThreshold::parse("0%").expect("valid share")),
3588            ..Selection::default()
3589        };
3590        let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
3591        assert_eq!(root.children.len(), 1);
3592        assert_eq!(root.children[0].name, "src");
3593        assert_eq!(root.children[0].children[0].name, "empty");
3594        let selection = Selection {
3595            include: vec![pattern("notes.txt")],
3596            depth: Some(Bound::Limit(0)),
3597            min_share: Some(ShareThreshold::parse("0%").expect("valid share")),
3598            ..Selection::default()
3599        };
3600        let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
3601        assert!(root.truncated, "a file leaf is hidden by depth zero");
3602        assert_eq!(root.bytes, 7);
3603    }
3604
3605    #[test]
3606    fn the_undocumented_docs_view_alias_is_rejected() {
3607        assert_eq!(
3608            ViewSpec::parse("documents").expect("the canonical view name parses"),
3609            ViewSpec::Documents
3610        );
3611        assert_eq!(
3612            ViewSpec::parse("docs").expect_err("an unreleased alias must not become a contract"),
3613            format!("expected one of {}", ViewSpec::vocabulary())
3614        );
3615    }
3616
3617    #[test]
3618    fn code_analysis_defaults_to_code_overview_and_keeps_document_projection() {
3619        assert_eq!(
3620            ViewSpec::resolve(None, AnalysisSet::NONE.with_code(), "view").expect("default").0,
3621            vec![ViewSpec::Code]
3622        );
3623        assert_eq!(
3624            ViewSpec::resolve(None, AnalysisSet::ALL, "view").expect("combined default").0,
3625            vec![ViewSpec::Code, ViewSpec::Documents]
3626        );
3627        assert_eq!(
3628            ViewSpec::resolve(Some("files"), AnalysisSet::ALL, "view").expect("explicit").0,
3629            vec![ViewSpec::Files]
3630        );
3631    }
3632
3633    fn generated_at() -> std::time::SystemTime {
3634        UNIX_EPOCH + Duration::from_secs(1_001)
3635    }
3636
3637    fn run(index: &Index, query: &Query) -> Report {
3638        report(index, &crate::test_support::read_of(index, query.clone()), generated_at())
3639            .expect("the query is answerable over this index")
3640    }
3641
3642    fn query(views: &[ViewSpec], selection: Selection) -> Query {
3643        Query { selection, views: views.to_vec(), ..Query::default() }
3644    }
3645
3646    /// A flat List: the projection whose rows carry subtree metrics and completeness.
3647    fn flat(selection: Selection) -> Query {
3648        Query {
3649            selection,
3650            views: vec![ViewSpec::List],
3651            format: crate::report_format::Format::Paths,
3652            ..Query::default()
3653        }
3654    }
3655
3656    fn pattern(source: &str) -> Pattern {
3657        Pattern::parse(source).expect("pattern compiles")
3658    }
3659
3660    fn summary_of(report: &Report) -> SummaryRow {
3661        match report.sections.first().expect("a section") {
3662            Section::Summary(row) => *row,
3663            other => panic!("expected a summary, got {other:?}"),
3664        }
3665    }
3666
3667    fn files_of(report: &Report) -> Vec<FileRow> {
3668        match report.sections.first().expect("a section") {
3669            Section::Files { rows, .. } => rows.clone(),
3670            other => panic!("expected files, got {other:?}"),
3671        }
3672    }
3673
3674    fn types_of(report: &Report) -> Vec<TypeRow> {
3675        match report.sections.first().expect("a section") {
3676            Section::Extensions { rows, .. } => rows.clone(),
3677            other => panic!("expected types, got {other:?}"),
3678        }
3679    }
3680
3681    fn tree_of(report: &Report) -> TreeNode {
3682        match report.sections.first().expect("a section") {
3683            Section::Tree { root: Some(node), .. } => (**node).clone(),
3684            other => panic!("expected a tree, got {other:?}"),
3685        }
3686    }
3687
3688    #[test]
3689    fn an_unfiltered_summary_matches_the_precomputed_rollup() {
3690        let index = sample();
3691        let row = summary_of(&run(&index, &query(&[ViewSpec::Summary], Selection::default())));
3692        assert_eq!(row.files, 5);
3693        assert_eq!(row.dirs, 3);
3694        assert_eq!(row.bytes, 657);
3695        assert_eq!(row.newest_mtime_ns, Some(40));
3696    }
3697
3698    #[test]
3699    fn the_two_tiers_agree_on_the_same_question() {
3700        // The load-bearing property: reading pre-computed roll-ups and re-aggregating a
3701        // filtered walk must answer identically when the filter admits everything.
3702        let index = sample();
3703        let fast = summary_of(&run(&index, &query(&[ViewSpec::Summary], Selection::default())));
3704
3705        // A filter that excludes nothing still forces the traversal tier.
3706        let admits_everything = Selection { min_size: Some(0), ..Selection::default() };
3707        assert!(!admits_everything.is_unfiltered());
3708        let slow = summary_of(&run(&index, &query(&[ViewSpec::Summary], admits_everything)));
3709
3710        // `dirs` belongs in this comparison like every other tally. It used to be left
3711        // out because the two tiers genuinely disagreed: the traversal tier counted every
3712        // directory it descended into, so a filter admitting everything was the only
3713        // filter the two tiers could agree under.
3714        assert_eq!(
3715            (fast.files, fast.dirs, fast.bytes, fast.allocated, fast.newest_mtime_ns),
3716            (slow.files, slow.dirs, slow.bytes, slow.allocated, slow.newest_mtime_ns)
3717        );
3718    }
3719
3720    #[test]
3721    fn extension_rows_account_for_every_file_in_both_tiers() {
3722        // The rows are a partition of the tree, not a selection from it, so they have to
3723        // sum to what the summary reports. They did not: a name with no extension was
3724        // dropped from the roll-up rather than bucketed, so a 657-byte tree came back as
3725        // rows totalling less and nothing in the output said which files were missing.
3726        let mut index = sample();
3727        index
3728            .apply(&Observation::new(vec![
3729                upsert("Makefile", EntryKind::File, attrs(28, 50)),
3730                upsert(".gitignore", EntryKind::File, attrs(11, 51)),
3731            ]))
3732            .expect("apply");
3733
3734        // Both tiers: unfiltered reads the pre-computed roll-up, and any filter at all
3735        // forces the traversal to re-aggregate. They are separate code paths.
3736        for selection in [
3737            Selection::default(),
3738            Selection { min_size: Some(0), ..Selection::default() },
3739            Selection { kinds: vec![EntryKind::File], ..Selection::default() },
3740        ] {
3741            let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], selection.clone())));
3742            let summary = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection.clone())));
3743            assert_eq!(
3744                rows.iter().map(|row| row.bytes).sum::<u64>(),
3745                summary.bytes,
3746                "bytes unaccounted for under {selection:?}: {rows:?}"
3747            );
3748            assert_eq!(
3749                rows.iter().map(|row| row.files).sum::<u64>(),
3750                summary.files,
3751                "files unaccounted for under {selection:?}: {rows:?}"
3752            );
3753        }
3754    }
3755
3756    #[test]
3757    fn names_without_an_extension_share_one_bucket() {
3758        // `Makefile` and `.gitignore` have nothing in common as names, and inventing a
3759        // row per such name would turn the view into a file listing. One bucket keeps it
3760        // a roll-up while still accounting for the bytes.
3761        let mut index = sample();
3762        index
3763            .apply(&Observation::new(vec![
3764                upsert("Makefile", EntryKind::File, attrs(28, 50)),
3765                upsert(".gitignore", EntryKind::File, attrs(11, 51)),
3766            ]))
3767            .expect("apply");
3768
3769        let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], Selection::default())));
3770        let bucket = rows
3771            .iter()
3772            .find(|row| row.extension == crate::classify::NO_EXTENSION)
3773            .expect("a bucket for the extension-less names");
3774        assert_eq!(bucket.files, 2);
3775        assert_eq!(bucket.bytes, 39);
3776        // And it never swallows a name that does have one.
3777        assert!(rows.iter().any(|row| row.extension == ".rs"), "{rows:?}");
3778    }
3779
3780    #[test]
3781    fn a_summary_counts_the_union_of_listed_entries_and_directory_contents() {
3782        // One query must not give two answers. The directory tally is folded from the
3783        // walk while the files view is filtered entry by entry, so they are two paths to
3784        // the same number and drifted apart: `--kind file` answered "5 files, 3
3785        // directories" while the files view under the same selection listed no directory
3786        // at all.
3787        let index = sample();
3788        for selection in [
3789            Selection { kinds: vec![EntryKind::File], ..Selection::default() },
3790            Selection { kinds: vec![EntryKind::Dir], ..Selection::default() },
3791            Selection { include: vec![pattern("*.rs")], ..Selection::default() },
3792            Selection { exclude: vec![pattern("docs")], ..Selection::default() },
3793            Selection { min_size: Some(1_000_000), ..Selection::default() },
3794            Selection { min_size: Some(0), ..Selection::default() },
3795        ] {
3796            let summary = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection.clone())));
3797            let listed = files_of(&run(&index, &query(&[ViewSpec::Files], selection.clone())));
3798            let dirs = listed.iter().filter(|row| row.kind == EntryKind::Dir).count() as u64;
3799            let files = every_entry(&index)
3800                .iter()
3801                .filter(|entry| {
3802                    entry.kind == EntryKind::File
3803                        && listed.iter().any(|row| {
3804                            entry.path == row.path
3805                                || (row.kind == EntryKind::Dir && entry.path.starts_with(&row.path))
3806                        })
3807                })
3808                .count() as u64;
3809            assert_eq!(summary.dirs, dirs, "directory counts disagree under {selection:?}");
3810            assert_eq!(summary.files, files, "file counts disagree under {selection:?}");
3811        }
3812    }
3813
3814    #[test]
3815    fn a_rejected_directory_is_still_descended_into() {
3816        // Filtering a directory out of the tally must not filter out what is under it:
3817        // `--kind file` reports no directories and every file, at every depth.
3818        let index = sample();
3819        let selection = Selection { kinds: vec![EntryKind::File], ..Selection::default() };
3820        let row = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection)));
3821        assert_eq!(row.dirs, 0, "no directory was admitted");
3822        assert_eq!(row.files, 5, "including src/deep/nested.rs, two levels down");
3823        assert_eq!(row.bytes, 657, "and its bytes");
3824    }
3825
3826    #[test]
3827    fn nested_directory_counts_roll_up_through_every_level() {
3828        // The tally is taken in the pre-order pass and folded in the post-order one, so a
3829        // directory admitted three levels down has to reach the root through both.
3830        let index = sample();
3831        let selection = Selection { kinds: vec![EntryKind::Dir], ..Selection::default() };
3832        let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
3833        assert_eq!(root.dirs, 3, "src, src/deep, and docs");
3834        assert_eq!(root.files, 4, "matching directories cover their regular files");
3835        let src = root.children.iter().find(|node| node.name == "src").expect("src");
3836        assert_eq!(src.dirs, 1, "src/deep, counted for src as well as for the root");
3837    }
3838
3839    #[test]
3840    fn selection_narrows_a_summary_to_what_it_admits() {
3841        let index = sample();
3842        let selection = Selection { include: vec![pattern("*.rs")], ..Selection::default() };
3843        let row = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection)));
3844        assert_eq!(row.files, 3, "three .rs files");
3845        assert_eq!(row.bytes, 350);
3846    }
3847
3848    #[test]
3849    fn a_files_view_lists_matching_entries_in_name_order_by_default() {
3850        let index = sample();
3851        let selection = Selection { include: vec![pattern("*.rs")], ..Selection::default() };
3852        let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
3853        // Built from components so the expectation carries the native separator: a
3854        // literal "src/main.rs" passes on Unix and fails on Windows for a reason that
3855        // has nothing to do with the view under test.
3856        let paths: Vec<PathBuf> = rows.iter().map(|row| row.path.clone()).collect();
3857        let expected: Vec<PathBuf> = [["src", "deep", "nested.rs"].iter().collect::<PathBuf>()]
3858            .into_iter()
3859            .chain([["src", "lib.rs"].iter().collect::<PathBuf>()])
3860            .chain([["src", "main.rs"].iter().collect::<PathBuf>()])
3861            .collect();
3862        assert_eq!(paths, expected);
3863    }
3864
3865    #[test]
3866    fn sorting_and_limiting_compose_without_a_dedicated_view() {
3867        // "Largest files" is not a view; it is files plus sort plus limit.
3868        let index = sample();
3869        // Apparent, because the sample's allocated sizes round to 512-byte blocks and tie.
3870        let selection = Selection {
3871            kinds: vec![EntryKind::File],
3872            sort: Some(SortKey::Size),
3873            limit: Some(Bound::Limit(2)),
3874            size: SizeMetric::Apparent,
3875            ..Selection::default()
3876        };
3877        let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
3878        assert_eq!(rows.len(), 2);
3879        assert_eq!(rows[0].bytes, 300, "largest first");
3880        assert_eq!(rows[1].bytes, 200);
3881    }
3882
3883    #[test]
3884    fn reverse_flips_whatever_order_is_in_effect() {
3885        let index = sample();
3886        let selection = Selection {
3887            kinds: vec![EntryKind::File],
3888            sort: Some(SortKey::Size),
3889            reverse: true,
3890            size: SizeMetric::Apparent,
3891            ..Selection::default()
3892        };
3893        let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
3894        assert_eq!(rows[0].bytes, 7, "smallest first once reversed");
3895    }
3896
3897    #[test]
3898    fn a_modified_window_selects_by_time() {
3899        let index = sample();
3900        let selection = Selection {
3901            kinds: vec![EntryKind::File],
3902            modified: ModifiedWindow { since: Some(20), before: Some(40) },
3903            sort: Some(SortKey::Mtime),
3904            ..Selection::default()
3905        };
3906        let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
3907        let mut times: Vec<i64> = rows.iter().map(|row| row.mtime_ns).collect();
3908        times.sort_unstable();
3909        assert_eq!(times, vec![20, 30], "inclusive start, exclusive end");
3910    }
3911
3912    #[test]
3913    fn a_types_view_reports_both_size_metrics_per_extension() {
3914        let index = sample();
3915        let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], Selection::default())));
3916        let rs = rows.iter().find(|row| row.extension == ".rs").expect(".rs present");
3917        assert_eq!((rs.files, rs.bytes), (3, 350));
3918        assert_eq!(rs.allocated, 1536, "three files, one 512-byte block each");
3919        // Size-ranked by default: .rs (350) then .md (300) then .txt (7).
3920        let order: Vec<&str> = rows.iter().map(|row| row.extension.as_str()).collect();
3921        assert_eq!(order, vec![".rs", ".md", ".txt"]);
3922    }
3923
3924    #[test]
3925    fn a_tree_view_reports_directories_with_their_subtree_totals() {
3926        let index = sample();
3927        let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], Selection::default())));
3928        assert_eq!(tree.name, ".");
3929        assert_eq!(tree.bytes, 657);
3930        // Size-ranked children: src (350) before docs (300).
3931        let names: Vec<&str> = tree.children.iter().map(|child| child.name.as_str()).collect();
3932        assert_eq!(names, vec!["src", "docs", "notes.txt"]);
3933        let src = &tree.children[0];
3934        assert_eq!(src.bytes, 350);
3935        let nested: Vec<&str> = src.children.iter().map(|child| child.name.as_str()).collect();
3936        assert!(nested.contains(&"deep"));
3937    }
3938
3939    #[test]
3940    fn depth_zero_keeps_dus_meaning_of_root_totals_only() {
3941        let index = sample();
3942        let selection = Selection { depth: Some(Bound::Limit(0)), ..Selection::default() };
3943        let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
3944        assert_eq!(tree.bytes, 657, "totals still cover the whole tree");
3945        assert!(tree.children.is_empty(), "but nothing below the root is listed");
3946        assert!(tree.truncated, "and the report says so rather than implying emptiness");
3947    }
3948
3949    /// A view `full` had to drop is named on the report, so every surface says so.
3950    ///
3951    /// This lived in the CLI, which meant a caller reaching the same wall through the
3952    /// library got a report quietly missing a section and no way to learn why (fdu-x8u6).
3953    #[test]
3954    fn a_dropped_view_is_named_on_the_report_rather_than_by_one_surface() {
3955        let (selected, omitted) = ViewSpec::resolve(Some("full"), AnalysisSet::NONE, "view")
3956            .expect("full resolves without analyzers");
3957        assert!(omitted.contains(&ViewSpec::Code));
3958        assert!(omitted.contains(&ViewSpec::Documents));
3959
3960        let query = Query { views: selected, omitted_views: omitted, ..Query::default() };
3961        let (notes, _) = display_notes(&query, &ControlCoverage::NotObserved);
3962        assert_eq!(notes.len(), 1, "{notes:?}");
3963        assert!(notes[0].contains("code") && notes[0].contains("documents"), "{notes:?}");
3964
3965        // Nothing dropped, nothing said.
3966        let (selected, omitted) = ViewSpec::resolve(Some("full"), AnalysisSet::ALL, "view")
3967            .expect("full resolves with analyzers");
3968        assert!(omitted.is_empty(), "every view is answerable with analysis enabled");
3969        let query = Query { views: selected, omitted_views: omitted, ..Query::default() };
3970        assert!(display_notes(&query, &ControlCoverage::NotObserved).0.is_empty());
3971    }
3972
3973    /// A rule belongs to the library; the words a caller can act on belong to their
3974    /// surface. Both diagnostics name two axes, and naming them with flags told a Python
3975    /// caller to add an `--analyze` their surface does not have (fdu-4apt).
3976    ///
3977    /// Asserted as "names mine, never the other's" rather than by quoting either sentence,
3978    /// so rewording the rule cannot break this and changing the vocabulary cannot pass it.
3979    /// The refused-controls note names a few directories and counts the rest, breaks the
3980    /// reasons down only when every refusal is listed, and raises exactly the limits that
3981    /// fired, each by its own knob.
3982    #[test]
3983    fn the_refused_controls_note_bounds_its_list_and_matches_its_remedy_to_the_reasons() {
3984        use crate::control::{
3985            ControlLimits, ControlObservation, ControlRefusalReason, RefusedControl,
3986        };
3987        let defaults = ControlLimits::default();
3988        for (reason, wanted, unwanted) in [
3989            (ControlRefusalReason::Budget, "--gitignore-budget", "--gitignore-line-limit"),
3990            (ControlRefusalReason::LineLimit, "--gitignore-line-limit", "--gitignore-budget"),
3991        ] {
3992            let coverage = ControlCoverage::Observed(ControlObservation {
3993                limits: defaults,
3994                applied: 7,
3995                rules: 0,
3996                refused: 1,
3997                refusals: vec![RefusedControl {
3998                    path: Path::new("vendor").join(".gitignore"),
3999                    reason,
4000                }],
4001            });
4002            let (note, tip) = refused_controls_note(&coverage, &AxisNames::FLAGS).expect("refusal");
4003            assert!(
4004                note.starts_with(
4005                    "note: ignore classification incomplete: 1 ignore file not applied"
4006                )
4007            );
4008            assert!(note.contains("vendor"));
4009            assert!(!note.contains("--"), "facts do not repeat flag advice");
4010            let tip = tip.expect("bounded refusal has a remedy");
4011            assert!(tip.starts_with("tip:"));
4012            assert!(tip.contains(wanted));
4013            assert!(!tip.contains(unwanted));
4014        }
4015        let coverage = |limits, refused| {
4016            ControlCoverage::Observed(ControlObservation {
4017                limits,
4018                applied: 7,
4019                rules: 0,
4020                refused,
4021                refusals: (0..crate::MAX_RETAINED_ISSUES)
4022                    .map(|i| RefusedControl {
4023                        path: Path::new(&format!("d{i:02}")).join(".gitignore"),
4024                        reason: ControlRefusalReason::Budget,
4025                    })
4026                    .collect(),
4027            })
4028        };
4029        let (note, tip) =
4030            refused_controls_note(&coverage(defaults, 1000), &AxisNames::FLAGS).expect("truncated");
4031        assert!(note.contains("995 more"));
4032        assert!(!note.contains("d05"));
4033        let tip = tip.expect("both bounded limits may explain unlisted refusals");
4034        assert!(tip.contains("--gitignore-budget") && tip.contains("--gitignore-line-limit"));
4035        let (_, tip) = refused_controls_note(
4036            &coverage(ControlLimits { line_limit: None, ..defaults }, 1000),
4037            &AxisNames::FLAGS,
4038        )
4039        .expect("truncated");
4040        assert!(!tip.expect("budget remedy").contains("--gitignore-line-limit"));
4041        let (_, tip) = refused_controls_note(
4042            &coverage(ControlLimits { budget: None, line_limit: None }, 64),
4043            &AxisNames::FLAGS,
4044        )
4045        .expect("retained refusal");
4046        assert!(tip.is_none(), "do not suggest raising unbounded limits");
4047        assert!(refused_controls_note(&ControlCoverage::NotObserved, &AxisNames::FLAGS).is_none());
4048    }
4049
4050    #[test]
4051    fn a_diagnostic_names_the_axes_the_requesting_surface_uses() {
4052        let (selected, omitted) = ViewSpec::resolve(Some("full"), AnalysisSet::NONE, "view")
4053            .expect("full resolves without analyzers");
4054
4055        for (axes, mine, theirs) in [
4056            (&AxisNames::FLAGS, "--analyze", "analyze"),
4057            (&AxisNames::FIELDS, "analyze", "--analyze"),
4058        ] {
4059            let query = Query {
4060                views: selected.clone(),
4061                omitted_views: omitted.clone(),
4062                axes,
4063                ..Query::default()
4064            };
4065            // Anchored on the whole phrase, because `--analyze` contains `analyze`: a bare
4066            // `contains` for the other surface's spelling matches its own. That is the same
4067            // tokenisation trap the watch-scope substitution had to avoid.
4068            let note = display_notes(&query, &ControlCoverage::NotObserved).1.remove(0);
4069            assert!(note.contains(&format!("add {mine} ")), "{note} must name {mine}");
4070            assert!(!note.contains(&format!("add {theirs} ")), "{note} must not name {theirs}");
4071        }
4072
4073        // The same for the hard error, which names the view axis as well. The rule is the
4074        // request model's; what a surface reads is this rendering of it.
4075        for (axes, view, analyze) in
4076            [(&AxisNames::FLAGS, "--view", "--analyze"), (&AxisNames::FIELDS, "view", "analyze")]
4077        {
4078            let error =
4079                crate::query::RequestError::ViewNeedsContent(ViewSpec::Documents).message(axes);
4080            assert!(error.starts_with(&format!("{view} documents")), "{error}");
4081            assert!(error.contains(&format!("add {analyze} ")), "{error}");
4082            let theirs = if analyze == "--analyze" { "analyze" } else { "--analyze" };
4083            assert!(!error.contains(&format!("add {theirs} ")), "{error}");
4084        }
4085    }
4086
4087    /// A library caller who never says otherwise is not the command line, so the default
4088    /// vocabulary is the one their surface uses.
4089    #[test]
4090    fn the_default_vocabulary_is_the_librarys_own() {
4091        assert_eq!(*Query::default().axes, AxisNames::FIELDS);
4092    }
4093
4094    #[test]
4095    fn a_depth_bound_marks_hidden_file_and_directory_rows_as_truncated() {
4096        let index = sample();
4097        let selection = Selection { depth: Some(Bound::Limit(1)), ..Selection::default() };
4098        let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4099
4100        let src = tree.children.iter().find(|child| child.name == "src").expect("src");
4101        assert!(src.truncated, "src with a hidden directory child is truncated");
4102
4103        let docs = tree.children.iter().find(|child| child.name == "docs").expect("docs");
4104        assert!(docs.children.is_empty());
4105        assert!(docs.truncated, "significant file leaves are rows beyond the depth boundary");
4106    }
4107
4108    #[test]
4109    fn a_tree_limit_caps_section_rows_including_the_root() {
4110        let index = sample();
4111        let selection = Selection { limit: Some(Bound::Limit(1)), ..Selection::default() };
4112        let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4113        assert_eq!(tree.children.len(), 0);
4114        assert!(tree.truncated);
4115        assert!(tree.omissions.iter().any(|omission| omission.reason == TreeOmissionReason::Rows));
4116    }
4117
4118    #[test]
4119    fn tree_share_is_root_relative_inclusive_and_does_not_hide_eleventh_sibling() {
4120        let mut index = Index::new("/root");
4121        let mut ops = Vec::new();
4122        for number in 0..11 {
4123            let directory = format!("d{number:02}");
4124            ops.push(upsert(&directory, EntryKind::Dir, Attrs::default()));
4125            ops.push(upsert(&format!("{directory}/file"), EntryKind::File, attrs(20, 1)));
4126        }
4127        ops.push(upsert("filler", EntryKind::File, attrs(780, 1)));
4128        index.apply(&Observation::new(ops)).expect("apply");
4129        let selection = Selection { size: SizeMetric::Apparent, ..Selection::default() };
4130        let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4131        assert_eq!(root.children.len(), 12, "eleven 2% directories plus the large file");
4132        assert!(root.children.iter().any(|child| child.name == "d10"));
4133        assert!(root.omissions.is_empty());
4134
4135        let selection = Selection {
4136            size: SizeMetric::Apparent,
4137            breadth: Some(Bound::Limit(10)),
4138            ..Selection::default()
4139        };
4140        let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4141        assert_eq!(root.children.len(), 10);
4142        assert_eq!(root.omissions[0].reason, TreeOmissionReason::Breadth);
4143        assert_eq!(root.omissions[0].entries, 2);
4144        assert_eq!(root.omissions[0].bytes, Some(40));
4145    }
4146
4147    #[test]
4148    fn tree_threshold_keeps_exact_one_percent_file_leaf() {
4149        let mut index = Index::new("/root");
4150        index
4151            .apply(&Observation::new(vec![
4152                upsert("one-percent", EntryKind::File, attrs(10, 1)),
4153                upsert("below", EntryKind::File, attrs(9, 1)),
4154                upsert("rest", EntryKind::File, attrs(981, 1)),
4155            ]))
4156            .expect("apply");
4157        let root = tree_of(&run(
4158            &index,
4159            &query(
4160                &[ViewSpec::Tree],
4161                Selection { size: SizeMetric::Apparent, ..Selection::default() },
4162            ),
4163        ));
4164        assert_eq!(root.children.len(), 2);
4165        assert!(
4166            root.children
4167                .iter()
4168                .any(|child| child.name == "one-percent" && child.kind == EntryKind::File)
4169        );
4170        assert_eq!(root.omissions[0].reason, TreeOmissionReason::Share);
4171        assert_eq!(root.omissions[0].bytes, Some(9));
4172    }
4173
4174    /// Each display boundary hides a disjoint subtree. The breadth omission covers
4175    /// two files under one directory, so omitted child roots cannot stand in for files.
4176    fn mixed_remainder_index() -> Index {
4177        let mut index = Index::new("/root");
4178        index.apply_ok(&Observation::new(vec![
4179            upsert("A", EntryKind::Dir, attrs(0, 0)),
4180            upsert("A/a1", EntryKind::Dir, attrs(0, 0)),
4181            upsert("A/a1/x", EntryKind::Dir, attrs(0, 0)),
4182            upsert("A/a1/x/leaf", EntryKind::File, attrs(1960, 0)),
4183            upsert("A/a1/tiny", EntryKind::File, attrs(40, 0)),
4184            upsert("A/a2", EntryKind::Dir, attrs(0, 0)),
4185            upsert("A/a2/file", EntryKind::File, attrs(2000, 0)),
4186            upsert("B", EntryKind::Dir, attrs(0, 0)),
4187            upsert("B/b1", EntryKind::Dir, attrs(0, 0)),
4188            upsert("B/b1/file", EntryKind::File, attrs(2000, 0)),
4189            upsert("B/b2", EntryKind::Dir, attrs(0, 0)),
4190            upsert("B/b2/file", EntryKind::File, attrs(2000, 0)),
4191            upsert("C", EntryKind::Dir, attrs(0, 0)),
4192            upsert("C/c1", EntryKind::Dir, attrs(0, 0)),
4193            upsert("C/c1/first", EntryKind::File, attrs(1000, 0)),
4194            upsert("C/c1/second", EntryKind::File, attrs(1000, 0)),
4195        ]));
4196        index
4197    }
4198
4199    #[test]
4200    fn nested_mixed_omissions_have_one_exact_remainder_golden() {
4201        let index = mixed_remainder_index();
4202        let selection = Selection {
4203            size: SizeMetric::Apparent,
4204            depth: Some(Bound::Limit(3)),
4205            breadth: Some(Bound::Limit(2)),
4206            limit: Some(Bound::Limit(8)),
4207            min_share: Some(ShareThreshold::parse("0.5%").expect("share")),
4208            ..Selection::default()
4209        };
4210        let report = run(&index, &query(&[ViewSpec::Tree], selection));
4211        let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
4212            panic!("expected tree")
4213        };
4214        let remainder = TreeRemainder::from_tree(Some(root), omissions).expect("hidden content");
4215        assert_eq!((root.files, root.bytes, root.allocated), (7, 10_000, 10_752));
4216        assert_eq!(
4217            (remainder.files, remainder.bytes, remainder.allocated),
4218            (Some(2), Some(2_000), Some(2_048))
4219        );
4220        assert_eq!(remainder.reasons, vec![TreeOmissionReason::Breadth]);
4221        assert_eq!(
4222            crate::report_format::render(&report, crate::report_format::Format::Text, false)
4223                .expect("render"),
4224            include_str!("../../tests/golden/remainder-tree.txt"),
4225        );
4226    }
4227
4228    #[test]
4229    fn displayed_directories_represent_their_descendants_even_when_expansion_stops() {
4230        let index = mixed_remainder_index();
4231        let selection = Selection {
4232            size: SizeMetric::Apparent,
4233            depth: Some(Bound::Limit(1)),
4234            breadth: Some(Bound::All),
4235            limit: Some(Bound::All),
4236            min_share: Some(ShareThreshold::parse("0%").expect("share")),
4237            ..Selection::default()
4238        };
4239        let report = run(&index, &query(&[ViewSpec::Tree], selection));
4240        let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
4241            panic!("expected tree")
4242        };
4243        assert_eq!((root.files, root.bytes), (7, 10_000));
4244        assert_eq!(root.children.len(), 3);
4245        assert!(root.children.iter().all(|child| child.kind == EntryKind::Dir));
4246        assert!(root.children.iter().any(|child| !child.omissions.is_empty()));
4247        assert!(TreeRemainder::from_tree(Some(root), omissions).is_none());
4248        let diagnostics = crate::report_format::diagnostic_lines(&report);
4249        assert!(!diagnostics.notes.iter().any(|note| note.contains("more covers")));
4250        assert!(diagnostics.notes.iter().any(|note| note.contains("depth 1")));
4251        assert!(
4252            diagnostics.tips.contains(&format!("tip: expand deeper: {}=all", report.axes.depth))
4253        );
4254
4255        let depth_zero = run(
4256            &index,
4257            &query(
4258                &[ViewSpec::Tree],
4259                Selection {
4260                    size: SizeMetric::Apparent,
4261                    depth: Some(Bound::Limit(0)),
4262                    min_share: Some(ShareThreshold::parse("0%").expect("share")),
4263                    ..Selection::default()
4264                },
4265            ),
4266        );
4267        let Section::Tree { root: Some(root), omissions, .. } = &depth_zero.sections[0] else {
4268            panic!("expected tree")
4269        };
4270        let remainder = TreeRemainder::from_tree(Some(root), omissions).expect("root alone");
4271        assert_eq!((remainder.files, remainder.bytes), (Some(7), Some(10_000)));
4272    }
4273
4274    #[test]
4275    fn selected_leaf_ledger_conserves_every_top_level_tree_partition() {
4276        // This ledger is independent of the index rollups and omission records. A listed
4277        // direct child represents all selected leaves below it, whether or not its own
4278        // children were expanded. The root is context, not a second represented set.
4279        let files = [
4280            ("src/main.rs", 100, 512, false),
4281            ("src/lib.rs", 200, 512, false),
4282            ("src/debug.log", 25, 512, true),
4283            ("docs/guide.md", 300, 512, false),
4284            ("build/cache/out.bin", 1_000, 1_024, true),
4285        ];
4286        let dirs = [("src", false), ("docs", false), ("build", true), ("build/cache", true)];
4287        let index = classified_sample();
4288        let within = |path: &str, parent: &Path| {
4289            parent.as_os_str().is_empty() || Path::new(path).starts_with(parent)
4290        };
4291        for (population, excluded) in [
4292            (IgnoredEntries::Include, None),
4293            (IgnoredEntries::Exclude, None),
4294            (IgnoredEntries::Only, None),
4295            (IgnoredEntries::Include, Some("build")),
4296            (IgnoredEntries::Include, Some("*.rs")),
4297        ] {
4298            let admitted = |path: &str, ignored: bool| {
4299                population.admits(ignored)
4300                    && !matches!(excluded, Some("build") if Path::new(path).starts_with("build"))
4301                    && !matches!(excluded, Some("*.rs") if Path::new(path)
4302                        .extension()
4303                        .is_some_and(|extension| extension.eq_ignore_ascii_case("rs")))
4304            };
4305            let selected_files: Vec<_> = files
4306                .iter()
4307                .copied()
4308                .filter(|(path, _, _, ignored)| admitted(path, *ignored))
4309                .collect();
4310            let selected_dirs: Vec<_> =
4311                dirs.iter().copied().filter(|(path, ignored)| admitted(path, *ignored)).collect();
4312            for size in [SizeMetric::Apparent, SizeMetric::Allocated] {
4313                for share in ["0%", "1%", "50%", "100%"] {
4314                    for depth in [Bound::Limit(0), Bound::Limit(1), Bound::All] {
4315                        for breadth in
4316                            [Bound::Limit(0), Bound::Limit(1), Bound::Limit(2), Bound::All]
4317                        {
4318                            for limit in
4319                                [Bound::Limit(0), Bound::Limit(1), Bound::Limit(3), Bound::All]
4320                            {
4321                                let selection = Selection {
4322                                    ignored: population,
4323                                    exclude: excluded
4324                                        .map_or_else(Vec::new, |name| vec![pattern(name)]),
4325                                    size,
4326                                    min_share: Some(ShareThreshold::parse(share).expect("share")),
4327                                    depth: Some(depth),
4328                                    breadth: Some(breadth),
4329                                    limit: Some(limit),
4330                                    ..Selection::default()
4331                                };
4332                                let report = run(
4333                                    &index,
4334                                    &query(&[ViewSpec::Summary, ViewSpec::Tree], selection.clone()),
4335                                );
4336                                let Section::Summary(summary) = &report.sections[0] else {
4337                                    panic!("summary")
4338                                };
4339                                let Section::Tree { root, omissions, .. } = &report.sections[1]
4340                                else {
4341                                    panic!("tree")
4342                                };
4343                                let expected = (
4344                                    selected_files.len() as u64,
4345                                    selected_dirs.len() as u64,
4346                                    selected_files.iter().map(|file| file.1).sum::<u64>(),
4347                                    selected_files.iter().map(|file| file.2).sum::<u64>(),
4348                                );
4349                                let context = format!("{selection:?}");
4350                                assert_eq!(
4351                                    (summary.files, summary.dirs, summary.bytes, summary.allocated),
4352                                    expected,
4353                                    "summary: {context}"
4354                                );
4355                                if let Some(root) = root {
4356                                    assert_eq!(
4357                                        (root.files, root.dirs, root.bytes, root.allocated),
4358                                        expected,
4359                                        "root: {context}"
4360                                    );
4361                                    let mut stack = vec![root.as_ref()];
4362                                    while let Some(node) = stack.pop() {
4363                                        let contained: Vec<_> = selected_files
4364                                            .iter()
4365                                            .filter(|file| within(file.0, &node.path))
4366                                            .collect();
4367                                        let contained_dirs = selected_dirs
4368                                            .iter()
4369                                            .filter(|dir| {
4370                                                within(dir.0, &node.path)
4371                                                    && Path::new(dir.0) != node.path
4372                                            })
4373                                            .count()
4374                                            as u64;
4375                                        let ignored: Vec<_> =
4376                                            contained.iter().filter(|file| file.3).collect();
4377                                        let ignored_dirs = selected_dirs
4378                                            .iter()
4379                                            .filter(|dir| {
4380                                                dir.1
4381                                                    && within(dir.0, &node.path)
4382                                                    && Path::new(dir.0) != node.path
4383                                            })
4384                                            .count()
4385                                            as u64;
4386                                        assert_eq!(
4387                                            (node.files, node.dirs, node.bytes, node.allocated),
4388                                            (
4389                                                contained.len() as u64,
4390                                                contained_dirs,
4391                                                contained.iter().map(|file| file.1).sum::<u64>(),
4392                                                contained.iter().map(|file| file.2).sum::<u64>(),
4393                                            ),
4394                                            "node {:?}: {context}",
4395                                            node.path
4396                                        );
4397                                        assert_eq!(
4398                                            node.ignored.map(|part| (
4399                                                part.files,
4400                                                part.dirs,
4401                                                part.bytes,
4402                                                part.allocated
4403                                            )),
4404                                            Some((
4405                                                ignored.len() as u64,
4406                                                ignored_dirs,
4407                                                ignored.iter().map(|file| file.1).sum::<u64>(),
4408                                                ignored.iter().map(|file| file.2).sum::<u64>(),
4409                                            )),
4410                                            "ignored {:?}: {context}",
4411                                            node.path
4412                                        );
4413                                        stack.extend(node.children.iter());
4414                                    }
4415                                }
4416                                let represented: Vec<_> = root
4417                                    .iter()
4418                                    .flat_map(|root| root.children.iter().map(|child| &child.path))
4419                                    .collect();
4420                                let uncovered: Vec<_> = selected_files
4421                                    .iter()
4422                                    .filter(|file| {
4423                                        !represented.iter().any(|path| within(file.0, path))
4424                                    })
4425                                    .collect();
4426                                let remainder =
4427                                    TreeRemainder::from_tree(root.as_deref(), omissions);
4428                                if let Some(remainder) = remainder {
4429                                    assert_eq!(
4430                                        (remainder.files, remainder.bytes, remainder.allocated),
4431                                        (
4432                                            Some(uncovered.len() as u64),
4433                                            Some(uncovered.iter().map(|file| file.1).sum::<u64>()),
4434                                            Some(uncovered.iter().map(|file| file.2).sum::<u64>()),
4435                                        ),
4436                                        "remainder: {context}"
4437                                    );
4438                                    assert_eq!(
4439                                        remainder.ignored.map(|part| (part.bytes, part.allocated)),
4440                                        Some((
4441                                            uncovered
4442                                                .iter()
4443                                                .filter(|file| file.3)
4444                                                .map(|file| file.1)
4445                                                .sum(),
4446                                            uncovered
4447                                                .iter()
4448                                                .filter(|file| file.3)
4449                                                .map(|file| file.2)
4450                                                .sum(),
4451                                        )),
4452                                        "remainder ignored: {context}"
4453                                    );
4454                                } else {
4455                                    assert!(uncovered.is_empty(), "missing remainder: {context}");
4456                                }
4457                            }
4458                        }
4459                    }
4460                }
4461            }
4462        }
4463    }
4464
4465    #[test]
4466    fn empty_tree_and_unrepresentable_remainder_stay_honest() {
4467        let empty = Index::new_with_scope("/root", crate::test_support::observing_controls());
4468        let report = run(
4469            &empty,
4470            &query(
4471                &[ViewSpec::Tree],
4472                Selection {
4473                    depth: Some(Bound::Limit(0)),
4474                    min_share: Some(ShareThreshold::parse("1%").expect("share")),
4475                    ..Selection::default()
4476                },
4477            ),
4478        );
4479        let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
4480            panic!("empty tree")
4481        };
4482        assert_eq!((root.files, root.dirs, root.bytes, root.allocated), (0, 0, 0, 0));
4483        assert!(TreeRemainder::from_tree(Some(root), omissions).is_none());
4484
4485        let first = TreeOmission {
4486            reason: TreeOmissionReason::Share,
4487            entries: 1,
4488            files: Some(u64::MAX),
4489            bytes: Some(u64::MAX),
4490            allocated: Some(u64::MAX),
4491            ignored: Some(IgnoredSize { bytes: u64::MAX, allocated: u64::MAX }),
4492        };
4493        let second = TreeOmission {
4494            reason: TreeOmissionReason::Breadth,
4495            entries: 1,
4496            files: Some(1),
4497            bytes: Some(1),
4498            allocated: None,
4499            ignored: Some(IgnoredSize { bytes: 1, allocated: 1 }),
4500        };
4501        let remainder = TreeRemainder::from_tree(None, &[first, second]).expect("omissions");
4502        assert_eq!((remainder.files, remainder.bytes, remainder.allocated), (None, None, None));
4503        assert_eq!(remainder.ignored, None);
4504    }
4505
4506    #[test]
4507    fn unbounded_tree_expands_every_entry_without_a_remainder_or_diagnostics() {
4508        let index = mixed_remainder_index();
4509        let selection = Selection {
4510            size: SizeMetric::Apparent,
4511            depth: Some(Bound::All),
4512            breadth: Some(Bound::All),
4513            limit: Some(Bound::All),
4514            min_share: Some(ShareThreshold::parse("0%").expect("share")),
4515            ..Selection::default()
4516        };
4517        let report = run(&index, &query(&[ViewSpec::Tree], selection));
4518        let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
4519            panic!("expected tree")
4520        };
4521        let mut paths = std::collections::BTreeSet::new();
4522        let mut stack = vec![root.as_ref()];
4523        while let Some(node) = stack.pop() {
4524            assert!(node.omissions.is_empty() && !node.truncated);
4525            paths.insert(node.path.to_string_lossy().replace('\\', "/"));
4526            stack.extend(&node.children);
4527        }
4528        assert_eq!(
4529            paths,
4530            [
4531                "",
4532                "A",
4533                "A/a1",
4534                "A/a1/tiny",
4535                "A/a1/x",
4536                "A/a1/x/leaf",
4537                "A/a2",
4538                "A/a2/file",
4539                "B",
4540                "B/b1",
4541                "B/b1/file",
4542                "B/b2",
4543                "B/b2/file",
4544                "C",
4545                "C/c1",
4546                "C/c1/first",
4547                "C/c1/second"
4548            ]
4549            .into_iter()
4550            .map(str::to_owned)
4551            .collect()
4552        );
4553        assert_eq!((root.files, root.dirs, root.bytes, root.allocated), (7, 9, 10_000, 10_752));
4554        assert!(omissions.is_empty());
4555        assert!(TreeRemainder::from_tree(Some(root), omissions).is_none());
4556        assert!(report.notes.is_empty() && report.tips.is_empty());
4557    }
4558
4559    #[test]
4560    fn an_unlisted_hidden_branch_makes_remainder_counts_and_sizes_unknown() {
4561        let mut index = Index::new("/root");
4562        index.apply_ok(&Observation::new(vec![
4563            upsert("known", EntryKind::File, attrs(100, 0)),
4564            upsert("denied", EntryKind::Dir, attrs(0, 0)),
4565        ]));
4566        index.set_initial_scan_freshness(&[crate::Error::io(
4567            Path::new("/root").join("denied"),
4568            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed listing"),
4569        )]);
4570        let selection = Selection {
4571            size: SizeMetric::Apparent,
4572            depth: Some(Bound::Limit(0)),
4573            min_share: Some(ShareThreshold::parse("0%").expect("share")),
4574            ..Selection::default()
4575        };
4576        let report = run(&index, &query(&[ViewSpec::Tree], selection));
4577        let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
4578            panic!("expected tree")
4579        };
4580        let remainder = TreeRemainder::from_tree(Some(root), omissions).expect("depth bound");
4581        assert_eq!((remainder.files, remainder.bytes, remainder.allocated), (None, None, None));
4582        assert_eq!(remainder.reasons, vec![TreeOmissionReason::Depth]);
4583        let text = crate::report_format::render(&report, crate::report_format::Format::Text, false)
4584            .expect("render");
4585        assert!(text.contains("—     unknown    … and more files (count unknown)"));
4586    }
4587
4588    /// A failed listing must not turn off the default threshold for verified siblings.
4589    /// The whole rendered report is golden; exact arithmetic and unknown retention stay
4590    /// visible together rather than passing as isolated bounds/status assertions.
4591    #[test]
4592    fn partial_tree_keeps_default_share_pruning_golden() {
4593        let mut index = Index::new("/root");
4594        index.apply_ok(&Observation::new(vec![
4595            upsert("large", EntryKind::File, attrs(9898, 0)),
4596            upsert("one-percent", EntryKind::File, attrs(100, 0)),
4597            upsert("tiny", EntryKind::File, attrs(1, 0)),
4598            upsert("zero", EntryKind::File, attrs(0, 0)),
4599            upsert("small", EntryKind::Dir, attrs(0, 0)),
4600            upsert("small/tiny", EntryKind::File, attrs(1, 0)),
4601            upsert("denied", EntryKind::Dir, attrs(0, 0)),
4602        ]));
4603        index.set_initial_scan_freshness(&[crate::Error::io(
4604            Path::new("/root").join("denied"),
4605            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed listing"),
4606        )]);
4607        let selection = Selection { size: SizeMetric::Apparent, ..Selection::default() };
4608        let report = run(&index, &query(&[ViewSpec::Tree], selection.clone()));
4609        assert!(!report.status.complete);
4610        assert_eq!(
4611            crate::report_format::render(&report, crate::report_format::Format::Text, false)
4612                .expect("render"),
4613            include_str!("../../tests/golden/partial-tree.txt"),
4614        );
4615        // A filtered partial tree uses the selected total (102 B), not the whole
4616        // observed root (10,000 B), while reusing selection's subtree measurement.
4617        let filtered = run(
4618            &index,
4619            &query(
4620                &[ViewSpec::Tree],
4621                Selection {
4622                    exclude: vec![pattern("large")],
4623                    min_share: Some(ShareThreshold::parse("50%").expect("share")),
4624                    ..selection.clone()
4625                },
4626            ),
4627        );
4628        let filtered_root = tree_of(&filtered);
4629        assert_eq!(filtered_root.children.len(), 2);
4630        assert_eq!(filtered_root.children[0].name, "one-percent");
4631        assert_eq!(filtered_root.children[1].name, "denied");
4632        let unbounded = run(
4633            &index,
4634            &query(
4635                &[ViewSpec::Tree],
4636                Selection {
4637                    min_share: Some(ShareThreshold::parse("0%").expect("share")),
4638                    ..selection
4639                },
4640            ),
4641        );
4642        assert_eq!(tree_of(&unbounded).children.len(), 6, "zero share lifts only share pruning");
4643    }
4644
4645    #[test]
4646    fn tree_zero_and_composed_caps_keep_typed_first_boundaries() {
4647        let index = sample();
4648        let selection = Selection { limit: Some(Bound::Limit(0)), ..Selection::default() };
4649        let report = run(&index, &query(&[ViewSpec::Tree], selection));
4650        let Section::Tree { root: None, omissions, .. } = &report.sections[0] else {
4651            panic!("zero row cap must omit the root")
4652        };
4653        assert_eq!(omissions[0].reason, TreeOmissionReason::Rows);
4654        assert_eq!(omissions[0].entries, 1);
4655
4656        let selection = Selection {
4657            size: SizeMetric::Apparent,
4658            depth: Some(Bound::Limit(1)),
4659            breadth: Some(Bound::Limit(1)),
4660            limit: Some(Bound::Limit(2)),
4661            min_share: Some(ShareThreshold::parse("1%").expect("share")),
4662            ..Selection::default()
4663        };
4664        let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4665        assert_eq!(root.children.len(), 1);
4666        assert!(
4667            root.omissions.iter().any(|omission| omission.reason == TreeOmissionReason::Breadth)
4668        );
4669        assert!(
4670            root.children[0]
4671                .omissions
4672                .iter()
4673                .any(|omission| omission.reason == TreeOmissionReason::Depth)
4674        );
4675    }
4676
4677    #[test]
4678    fn requesting_more_views_never_changes_another_views_answer() {
4679        // The property that makes `--view types,tree` one scan and one consistent state.
4680        let index = sample();
4681        let alone = types_of(&run(&index, &query(&[ViewSpec::Extensions], Selection::default())));
4682        let together = run(
4683            &index,
4684            &query(
4685                &[ViewSpec::Extensions, ViewSpec::Tree, ViewSpec::Summary],
4686                Selection::default(),
4687            ),
4688        );
4689        let with_others = match &together.sections[0] {
4690            Section::Extensions { rows, .. } => rows.clone(),
4691            other => panic!("expected types first, got {other:?}"),
4692        };
4693
4694        assert_eq!(alone.len(), with_others.len());
4695        for (left, right) in alone.iter().zip(with_others.iter()) {
4696            assert_eq!(
4697                (&left.extension, left.files, left.bytes),
4698                (&right.extension, right.files, right.bytes)
4699            );
4700        }
4701        assert_eq!(together.sections.len(), 3, "one section per view, in request order");
4702        assert_eq!(together.sections[1].view(), ViewSpec::Tree);
4703        assert_eq!(together.sections[2].view(), ViewSpec::Summary);
4704    }
4705
4706    #[test]
4707    fn analyzed_unfiltered_views_together_match_independent_answers_and_each_view_alone() {
4708        const RUST: &str = "fn main() {\n    println!(\"hi\");\n}\n";
4709        const MARKDOWN: &str = "# Guide\n\nA small useful guide.\n";
4710        const TEXT: &str = "plain notes here\n";
4711        let root = tempfile::tempdir().expect("root");
4712        fs::create_dir_all(root.path().join("src")).expect("src");
4713        fs::create_dir_all(root.path().join("docs")).expect("docs");
4714        fs::write(root.path().join("src/main.rs"), RUST).expect("rust");
4715        fs::write(root.path().join("docs/guide.md"), MARKDOWN).expect("markdown");
4716        fs::write(root.path().join("notes.txt"), TEXT).expect("text");
4717        for (path, seconds) in [("src/main.rs", 10), ("notes.txt", 20), ("docs/guide.md", 30)] {
4718            fs::File::options()
4719                .write(true)
4720                .open(root.path().join(path))
4721                .expect("open for timestamp")
4722                .set_times(
4723                    fs::FileTimes::new().set_modified(UNIX_EPOCH + Duration::from_secs(seconds)),
4724                )
4725                .expect("set timestamp");
4726        }
4727        let (mut index, _) = crate::scan::scan_into_index(
4728            root.path(),
4729            &crate::ScanConfig { read_controls: false, ..crate::ScanConfig::default() },
4730        )
4731        .expect("scan");
4732        crate::content::analyze_index(
4733            &mut index,
4734            crate::content::AnalysisRequest {
4735                profile: AnalysisSet::ALL,
4736                ..crate::content::AnalysisRequest::default()
4737            },
4738        );
4739
4740        let views = [
4741            ViewSpec::Types,
4742            ViewSpec::Families,
4743            ViewSpec::Languages,
4744            ViewSpec::Documents,
4745            ViewSpec::Files,
4746            ViewSpec::Largest,
4747            ViewSpec::Recent,
4748            ViewSpec::Summary,
4749            ViewSpec::Tree,
4750            ViewSpec::Extensions,
4751        ];
4752        let selection = Selection { size: SizeMetric::Apparent, ..Selection::default() };
4753        let together = run(&index, &query(&views, selection.clone()));
4754        for (i, view) in views.iter().enumerate() {
4755            let alone = run(&index, &query(&[*view], selection.clone()));
4756            assert_eq!(
4757                format!("{:?}", together.sections[i]),
4758                format!("{:?}", alone.sections[0]),
4759                "{view:?} changed when requested with the other views"
4760            );
4761        }
4762
4763        for (at, view, files) in [(0, ViewSpec::Types, 3), (1, ViewSpec::Families, 3)] {
4764            let Section::Metrics { view: actual, summary } = &together.sections[at] else {
4765                panic!("expected {view:?} metrics")
4766            };
4767            assert_eq!(*actual, view);
4768            assert_eq!(summary.total.files, files);
4769            assert_eq!(summary.total.analyzed_files, files);
4770        }
4771        let Section::Metrics { summary: languages, .. } = &together.sections[2] else {
4772            panic!("languages")
4773        };
4774        assert_eq!(languages.total.files, 1);
4775        assert_eq!(languages.total.metrics.code_lines, Some(3));
4776        let Section::Metrics { summary: documents, .. } = &together.sections[3] else {
4777            panic!("documents")
4778        };
4779        assert_eq!(documents.total.files, 2);
4780        assert_eq!(documents.total.analyzed_files, 2);
4781        assert_eq!(documents.total.document_metric_files, 2);
4782        assert_eq!(documents.total.document_raw_words, 8);
4783        assert_eq!(documents.total.document_word_stats.logical_words(), 8);
4784        assert_eq!(documents.total.share, MetricShare { numerator: 8, denominator: 8 });
4785
4786        let Section::Files { rows: files, total, .. } = &together.sections[4] else {
4787            panic!("files")
4788        };
4789        assert_eq!(*total, 5);
4790        assert_eq!(
4791            files.iter().map(|row| row.path.as_path()).collect::<Vec<_>>(),
4792            ["docs", "docs/guide.md", "notes.txt", "src", "src/main.rs"].map(Path::new).to_vec()
4793        );
4794        let Section::Files { rows: largest, total, .. } = &together.sections[5] else {
4795            panic!("largest")
4796        };
4797        assert_eq!(*total, 3);
4798        assert_eq!(
4799            largest.iter().map(|row| row.path.as_path()).collect::<Vec<_>>(),
4800            ["src/main.rs", "docs/guide.md", "notes.txt"].map(Path::new).to_vec()
4801        );
4802        let Section::Files { rows: recent, total, .. } = &together.sections[6] else {
4803            panic!("recent")
4804        };
4805        assert_eq!(*total, 3);
4806        assert_eq!(
4807            recent.iter().map(|row| row.path.as_path()).collect::<Vec<_>>(),
4808            ["docs/guide.md", "notes.txt", "src/main.rs"].map(Path::new).to_vec()
4809        );
4810        let Section::Summary(summary) = &together.sections[7] else { panic!("summary") };
4811        assert_eq!((summary.files, summary.dirs), (3, 2));
4812        assert_eq!(
4813            summary.bytes,
4814            u64::try_from(RUST.len() + MARKDOWN.len() + TEXT.len()).expect("fixture bytes")
4815        );
4816        let Section::Tree { root: Some(tree), .. } = &together.sections[8] else { panic!("tree") };
4817        assert_eq!((tree.files, tree.dirs), (3, 2));
4818        let Section::Extensions { rows, total, .. } = &together.sections[9] else {
4819            panic!("extensions")
4820        };
4821        assert_eq!((*total, rows.len()), (3, 3));
4822    }
4823
4824    #[test]
4825    fn one_pass_metric_summaries_match_independent_views() {
4826        let root = tempfile::tempdir().expect("root");
4827        fs::write(root.path().join("main.rs"), "fn main() {}\n").expect("rust");
4828        fs::write(root.path().join("guide.md"), "# Guide\n\nWords.\n").expect("markdown");
4829        fs::write(root.path().join(".gitignore"), "generated/\n").expect("ignore rules");
4830        fs::create_dir(root.path().join("generated")).expect("generated directory");
4831        fs::write(root.path().join("generated/app.js"), "const a = 1;\nconst b = 2;\n")
4832            .expect("ignored code");
4833        fs::write(root.path().join("script"), "#!/bin/sh\necho hello\n").expect("detected code");
4834        let (mut index, _) =
4835            crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
4836        crate::content::analyze_index(
4837            &mut index,
4838            crate::content::AnalysisRequest {
4839                profile: AnalysisSet::ALL,
4840                ..crate::content::AnalysisRequest::default()
4841            },
4842        );
4843
4844        let views = [
4845            ViewSpec::Types,
4846            ViewSpec::Summary,
4847            ViewSpec::Families,
4848            ViewSpec::Languages,
4849            ViewSpec::Documents,
4850            ViewSpec::Code,
4851        ];
4852        let request = query(&views, Selection::default());
4853        let rows = every_entry(&index);
4854        let summaries = metric_summaries(&views, &index, &request, AnalysisSet::ALL, &rows);
4855
4856        assert!(summaries[1].is_none(), "non-metric views keep their own projection");
4857        assert!(summaries[5].is_none(), "Code keeps its admitted-content classification");
4858        for (position, view) in
4859            views.iter().copied().enumerate().filter(|(_, view)| needs_metric_resolution(*view))
4860        {
4861            let independent =
4862                metric_summary(view, &index, &request, AnalysisSet::ALL, None, Some(&rows));
4863            assert_eq!(
4864                format!("{:?}", summaries[position].as_ref().expect("metric summary")),
4865                format!("{independent:?}"),
4866                "{view:?} changed in the one-pass multi-view aggregation"
4867            );
4868        }
4869
4870        // Exercise the integrated population and presentation controls through the report
4871        // boundary. Include uses the shared pass; Exclude and Only use the filtered path.
4872        // Content-detected extensionless code also keeps Code's classification distinct.
4873        // Summary alone does not accept a share threshold, so compare additive views here.
4874        let views = views.into_iter().filter(|view| *view != ViewSpec::Summary).collect::<Vec<_>>();
4875        for ignored in [IgnoredEntries::Include, IgnoredEntries::Exclude, IgnoredEntries::Only] {
4876            for sort in [None, Some(SortKey::Metric("code_lines")), Some(SortKey::Name)] {
4877                for min_share in [None, Some(ShareThreshold::parse("50%").expect("share"))] {
4878                    let selection = Selection {
4879                        ignored,
4880                        sort,
4881                        min_share,
4882                        limit: Some(Bound::Limit(1)),
4883                        ..Selection::default()
4884                    };
4885                    let together = run(&index, &query(&views, selection.clone()));
4886                    for (position, view) in views.iter().enumerate() {
4887                        let alone = run(&index, &query(&[*view], selection.clone()));
4888                        assert_eq!(
4889                            format!("{:?}", together.sections[position]),
4890                            format!("{:?}", alone.sections[0]),
4891                            "{view:?} differs for {selection:?}"
4892                        );
4893                    }
4894                }
4895            }
4896        }
4897    }
4898
4899    #[test]
4900    fn code_overview_keeps_a_complete_language_table_and_population_contributions() {
4901        let root = tempfile::tempdir().expect("root");
4902        fs::create_dir_all(root.path().join("generated")).expect("generated");
4903        fs::write(root.path().join(".gitignore"), "generated/\n").expect("ignore rules");
4904        fs::create_dir_all(root.path().join("src")).expect("src");
4905        fs::write(root.path().join("src/main.rs"), "fn main() {\n}\n// comment\n").expect("rust");
4906        fs::write(
4907            root.path().join("generated/app.js"),
4908            "// Code generated by fixture\nconst answer = 42;\n",
4909        )
4910        .expect("javascript");
4911        fs::write(root.path().join("README.md"), "# Guide\n").expect("documentation");
4912        fs::write(root.path().join("mystery.widget"), "opaque text\n").expect("unknown type");
4913        let (mut index, _) =
4914            crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
4915        crate::content::analyze_index(
4916            &mut index,
4917            crate::content::AnalysisRequest { profile: AnalysisSet::NONE.with_code(), workers: 1 },
4918        );
4919        let answer = run(&index, &query(&[ViewSpec::Code], Selection::default()));
4920        let Section::Code(overview) = &answer.sections[0] else { panic!("code overview") };
4921        assert_eq!(overview.selected.metrics.code_lines, 3);
4922        assert_eq!(overview.selected.metrics.comment_lines, 2);
4923        assert_eq!(overview.selected.analyzed_files, 2);
4924        assert_eq!(overview.analyzed_languages, 2);
4925        assert_eq!(overview.unclassified_files, 2); // .gitignore and mystery.widget
4926        assert_eq!(overview.languages.len(), 2);
4927        assert_eq!(overview.languages[0].share.denominator, 3);
4928        assert_eq!(overview.non_ignored.as_ref().expect("classified").metrics.code_lines, 2);
4929        assert_eq!(overview.ignored.as_ref().expect("classified").metrics.code_lines, 1);
4930        assert_eq!(overview.unknown.source_files, 0);
4931
4932        for (population, expected_lines) in
4933            [(IgnoredEntries::Exclude, 2), (IgnoredEntries::Only, 1)]
4934        {
4935            let selected = run(
4936                &index,
4937                &query(
4938                    &[ViewSpec::Code],
4939                    Selection { ignored: population, ..Selection::default() },
4940                ),
4941            );
4942            let Section::Code(overview) = &selected.sections[0] else { panic!("population") };
4943            assert_eq!(overview.selected.metrics.code_lines, expected_lines);
4944            assert!(overview.non_ignored.is_none() && overview.ignored.is_none());
4945            assert_eq!(overview.unknown.source_files, 0);
4946        }
4947
4948        let threshold = run(
4949            &index,
4950            &query(
4951                &[ViewSpec::Code],
4952                Selection {
4953                    min_share: Some(ShareThreshold::parse("50%").expect("share")),
4954                    ..Selection::default()
4955                },
4956            ),
4957        );
4958        let Section::Code(overview) = &threshold.sections[0] else { panic!("threshold") };
4959        assert_eq!(overview.selected.metrics.code_lines, 3);
4960        assert_eq!(overview.total_languages, 1);
4961        assert_eq!(overview.share_omitted, 1);
4962        assert_eq!(overview.languages[0].language, "rust");
4963
4964        let bounded = run(
4965            &index,
4966            &query(
4967                &[ViewSpec::Code],
4968                Selection { limit: Some(Bound::Limit(0)), ..Selection::default() },
4969            ),
4970        );
4971        let Section::Code(overview) = &bounded.sections[0] else { panic!("bounded") };
4972        assert_eq!(overview.selected.metrics.code_lines, 3);
4973        assert_eq!(overview.total_languages, 2);
4974        assert_eq!(overview.share_omitted, 0);
4975        assert!(overview.languages.is_empty());
4976
4977        let code_and_extensions = run(
4978            &index,
4979            &query(
4980                &[ViewSpec::Languages, ViewSpec::Extensions],
4981                Selection {
4982                    min_share: Some(ShareThreshold::parse("100%").expect("share")),
4983                    ..Selection::default()
4984                },
4985            ),
4986        );
4987        let Section::Metrics { summary, .. } = &code_and_extensions.sections[0] else {
4988            panic!("languages")
4989        };
4990        let Section::Extensions { rows, total, share_omitted } = &code_and_extensions.sections[1]
4991        else {
4992            panic!("extensions")
4993        };
4994        assert_eq!(summary.total.metrics.code_lines, Some(3));
4995        assert!(summary.rows.is_empty() && summary.total_rows == 0);
4996        assert_eq!(summary.share_omitted, 2);
4997        assert!(rows.is_empty() && *total == 0);
4998        assert!(*share_omitted > 0);
4999
5000        let reversed_code = run(
5001            &index,
5002            &query(&[ViewSpec::Code], Selection { reverse: true, ..Selection::default() }),
5003        );
5004        let Section::Code(reversed) = &reversed_code.sections[0] else { panic!("reversed code") };
5005        assert_eq!(reversed.languages[0].language, "javascript");
5006        assert_eq!(reversed.languages[1].language, "rust");
5007
5008        let named_code = run(
5009            &index,
5010            &query(
5011                &[ViewSpec::Code],
5012                Selection { sort: Some(SortKey::Name), ..Selection::default() },
5013            ),
5014        );
5015        let Section::Code(named) = &named_code.sections[0] else { panic!("named code") };
5016        assert_eq!(named.languages[0].language, "javascript");
5017        assert_eq!(named.languages[1].language, "rust");
5018
5019        let sized_code = run(
5020            &index,
5021            &query(
5022                &[ViewSpec::Code],
5023                Selection {
5024                    sort: Some(SortKey::Size),
5025                    size: SizeMetric::Apparent,
5026                    ..Selection::default()
5027                },
5028            ),
5029        );
5030        let Section::Code(sized) = &sized_code.sections[0] else { panic!("sized code") };
5031        assert_eq!(sized.languages[0].language, "javascript");
5032
5033        let files = |reverse| {
5034            let selection = Selection {
5035                kinds: vec![EntryKind::File],
5036                sort: Some(SortKey::Metric("code_lines")),
5037                reverse,
5038                ..Selection::default()
5039            };
5040            run(&index, &query(&[ViewSpec::Files], selection))
5041        };
5042        let descending = files(false);
5043        let Section::Files { rows, .. } = &descending.sections[0] else { panic!("files") };
5044        assert_eq!(rows[0].path, PathBuf::from("src/main.rs"));
5045        assert_eq!(rows[0].sort_value, Some(2));
5046        assert_eq!(rows[1].path, PathBuf::from("generated/app.js"));
5047        assert_eq!(rows[1].sort_value, Some(1));
5048        assert!(rows[2..].iter().all(|row| row.sort_value.is_none()));
5049        let classification = rows[1].classification.as_ref().expect("file classification");
5050        assert!(classification.flags.generated);
5051
5052        let ascending = files(true);
5053        let Section::Files { rows, .. } = &ascending.sections[0] else { panic!("files") };
5054        assert_eq!((rows[0].sort_value, rows[1].sort_value), (Some(1), Some(2)));
5055        assert!(rows[2..].iter().all(|row| row.sort_value.is_none()));
5056
5057        let mut dir_query = query(
5058            &[ViewSpec::List],
5059            Selection {
5060                kinds: vec![EntryKind::Dir],
5061                sort: Some(SortKey::Metric("code_lines")),
5062                ..Selection::default()
5063            },
5064        );
5065        dir_query.format = crate::report_format::Format::Json;
5066        let dirs = run(&index, &dir_query);
5067        let Section::Files { rows, .. } = &dirs.sections[0] else { panic!("directories") };
5068        assert_eq!((rows[0].path.as_path(), rows[0].sort_value), (Path::new("src"), Some(2)));
5069        assert_eq!((rows[1].path.as_path(), rows[1].sort_value), (Path::new("generated"), Some(1)));
5070    }
5071
5072    #[test]
5073    fn code_overview_counts_selected_directory_members_once() {
5074        let root = tempfile::tempdir().expect("root");
5075        fs::create_dir_all(root.path().join("src/deep")).expect("directories");
5076        fs::write(root.path().join("src/main.rs"), "fn main() {}\n").expect("main");
5077        fs::write(root.path().join("src/deep/keep.rs"), "fn keep() {}\n").expect("keep");
5078        fs::write(root.path().join("src/deep/skip.rs"), "fn skip() {}\n").expect("skip");
5079        fs::write(root.path().join("outside.rs"), "fn outside() {}\n").expect("outside");
5080        let (mut index, _) =
5081            crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
5082        crate::content::analyze_index(
5083            &mut index,
5084            crate::content::AnalysisRequest { profile: AnalysisSet::NONE.with_code(), workers: 1 },
5085        );
5086        let selection = Selection {
5087            include: vec![pattern("src"), pattern("deep")],
5088            exclude: vec![pattern("skip.rs")],
5089            ..Selection::default()
5090        };
5091        let answer = run(&index, &query(&[ViewSpec::Code], selection));
5092        let Section::Code(overview) = &answer.sections[0] else { panic!("code overview") };
5093        assert_eq!(overview.selected.source_files, 2);
5094        assert_eq!(overview.selected.analyzed_files, 2);
5095        assert_eq!(overview.selected.metrics.code_lines, 2);
5096        assert_eq!(overview.languages.len(), 1);
5097        assert_eq!(overview.languages[0].selected.source_files, 2);
5098    }
5099
5100    #[test]
5101    fn code_overview_uses_retained_content_detection_for_ambiguous_sources() {
5102        let root = tempfile::tempdir().expect("root");
5103        fs::write(root.path().join("ambiguous.h"), "namespace demo { int value; }\n")
5104            .expect("header");
5105        fs::write(root.path().join("script.inc"), "# vim: set filetype=rust:\nfn main() {}\n")
5106            .expect("modeline");
5107        let (mut index, _) =
5108            crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
5109        crate::content::analyze_index(
5110            &mut index,
5111            crate::content::AnalysisRequest { profile: AnalysisSet::NONE.with_code(), workers: 1 },
5112        );
5113        let answer = run(&index, &query(&[ViewSpec::Code], Selection::default()));
5114        let Section::Code(overview) = &answer.sections[0] else { panic!("code overview") };
5115        assert_eq!(overview.selected.source_files, 2);
5116        assert_eq!(overview.selected.analyzed_files, 2);
5117        assert!(overview.languages.iter().any(|row| row.language == "cpp"));
5118        assert!(overview.languages.iter().any(|row| row.language == "rust"));
5119    }
5120
5121    #[test]
5122    fn refused_control_subtrees_do_not_enter_a_known_ignored_population() {
5123        let root = tempfile::tempdir().expect("root");
5124        fs::create_dir_all(root.path().join("guarded")).expect("directory");
5125        fs::write(root.path().join("known.rs"), "fn known() {}\n").expect("known source");
5126        fs::write(root.path().join("guarded/.gitignore"), "*.rs\n").expect("refused control");
5127        fs::write(root.path().join("guarded/uncertain.rs"), "fn uncertain() {}\n")
5128            .expect("uncertain source");
5129        let config = crate::ScanConfig {
5130            control_limits: crate::control::ControlLimits {
5131                line_limit: Some(1),
5132                ..crate::control::ControlLimits::default()
5133            },
5134            ..crate::ScanConfig::default()
5135        };
5136        let (index, _) = crate::scan::scan_into_index(root.path(), &config).expect("scan");
5137        assert_eq!(index.ignored_classification(Path::new("known.rs")), Some(false));
5138        assert_eq!(index.ignored_classification(Path::new("guarded/uncertain.rs")), None);
5139        for selection in
5140            [Selection::default(), Selection { min_size: Some(0), ..Selection::default() }]
5141        {
5142            let report = run(
5143                &index,
5144                &query(&[ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions], selection),
5145            );
5146            let Section::Summary(summary) = &report.sections[0] else { panic!("summary") };
5147            let Section::Tree { root: Some(tree), .. } = &report.sections[1] else {
5148                panic!("tree")
5149            };
5150            let Section::Extensions { rows, .. } = &report.sections[2] else {
5151                panic!("extensions")
5152            };
5153            assert_eq!(summary.ignored, None);
5154            assert_eq!(tree.ignored, None);
5155            assert_eq!(
5156                tree.children
5157                    .iter()
5158                    .find(|node| node.path == Path::new("known.rs"))
5159                    .and_then(|node| node.ignored),
5160                Some(IgnoredTally::default())
5161            );
5162            assert!(
5163                tree.children
5164                    .iter()
5165                    .find(|node| node.path == Path::new("guarded"))
5166                    .is_some_and(|node| node.ignored.is_none())
5167            );
5168            assert!(rows.iter().all(|row| row.ignored.is_none()));
5169            assert!(
5170                report
5171                    .notes
5172                    .iter()
5173                    .any(|note| note.contains("gitignored subtotals are unavailable"))
5174            );
5175            assert!(
5176                crate::report_format::report_notes(&report)
5177                    .iter()
5178                    .all(|note| !note.contains("gitignored sizes are included"))
5179            );
5180        }
5181        let hidden_report = run(
5182            &index,
5183            &query(
5184                &[ViewSpec::Tree],
5185                Selection { depth: Some(Bound::Limit(0)), ..Selection::default() },
5186            ),
5187        );
5188        let Section::Tree { root, omissions, .. } = &hidden_report.sections[0] else {
5189            panic!("expected tree")
5190        };
5191        let hidden = TreeRemainder::from_tree(root.as_deref(), omissions).expect("hidden rows");
5192        assert!(hidden.bytes.is_some() && hidden.ignored.is_none());
5193        for population in [IgnoredEntries::Exclude, IgnoredEntries::Only] {
5194            let answer = run(
5195                &index,
5196                &query(
5197                    &[ViewSpec::Files],
5198                    Selection {
5199                        kinds: vec![EntryKind::File],
5200                        ignored: population,
5201                        ..Selection::default()
5202                    },
5203                ),
5204            );
5205            let Section::Files { rows, .. } = &answer.sections[0] else { panic!("files") };
5206            assert!(rows.iter().all(|row| !row.path.starts_with("guarded")));
5207            assert_eq!(
5208                rows.iter().any(|row| row.path == Path::new("known.rs")),
5209                population == IgnoredEntries::Exclude
5210            );
5211        }
5212    }
5213
5214    #[test]
5215    fn a_report_derives_provenance_from_its_index() {
5216        let index = sample();
5217        let report = run(&index, &query(&[ViewSpec::Summary], Selection::default()));
5218        assert_eq!(report.provenance.source, ReportSource::ColdScan);
5219        assert!(report.status.complete);
5220        assert!(report.provenance.scan_started_at.is_some());
5221        assert_eq!(report.provenance.generated_at, generated_at());
5222        assert_eq!(report.root, Path::new("/root"));
5223    }
5224
5225    #[test]
5226    fn reporting_is_pure_and_repeatable() {
5227        let index = sample();
5228        let request = query(&[ViewSpec::Tree, ViewSpec::Extensions], Selection::default());
5229        assert_eq!(format!("{:?}", run(&index, &request)), format!("{:?}", run(&index, &request)));
5230    }
5231
5232    #[test]
5233    fn metadata_grouping_views_use_the_generic_metric_projection() {
5234        let index = sample();
5235        let apparent = Selection { size: SizeMetric::Apparent, ..Selection::default() };
5236        let report = run(
5237            &index,
5238            &query(&[ViewSpec::Types, ViewSpec::Families, ViewSpec::Languages], apparent),
5239        );
5240        let Section::Metrics { summary: types, .. } = &report.sections[0] else {
5241            panic!("expected type metrics")
5242        };
5243        let rust = types.rows.iter().find(|row| row.id == "rust").expect("rust");
5244        assert_eq!((rust.files, rust.bytes), (3, 350));
5245        assert_eq!((rust.share.numerator, rust.share.denominator), (350, 657));
5246        assert_eq!(types.share_metric, ShareMetric::ApparentBytes);
5247
5248        let Section::Metrics { summary: families, .. } = &report.sections[1] else {
5249            panic!("expected family metrics")
5250        };
5251        assert!(families.rows.iter().any(|row| row.id == "code"));
5252        assert!(families.rows.iter().any(|row| row.id == "prose"));
5253        assert_eq!(families.share_metric, ShareMetric::ApparentBytes);
5254
5255        let Section::Metrics { summary: languages, .. } = &report.sections[2] else {
5256            panic!("expected language metrics")
5257        };
5258        let rust = languages.rows.iter().find(|row| row.id == "rust").expect("rust");
5259        assert_eq!((rust.files, rust.bytes), (3, 350));
5260        assert_eq!((rust.share.numerator, rust.share.denominator), (350, 350));
5261        assert_eq!(languages.share_metric, ShareMetric::ApparentBytes);
5262    }
5263
5264    /// The control file's name, spelled once for the fixtures that write one.
5265    const CONTROL: &str = ".gitignore";
5266
5267    /// A small tree under a control source ignoring `build/` and `*.log`.
5268    fn classified_sample() -> Index {
5269        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
5270        index
5271            .apply(&Observation::new(vec![
5272                Op::ControlUpsert {
5273                    path: PathBuf::from(CONTROL),
5274                    source: b"build/\n*.log\n".to_vec(),
5275                },
5276                upsert("src", EntryKind::Dir, Attrs::default()),
5277                upsert("src/main.rs", EntryKind::File, attrs(100, 10)),
5278                upsert("src/lib.rs", EntryKind::File, attrs(200, 20)),
5279                upsert("src/debug.log", EntryKind::File, attrs(25, 70)),
5280                upsert("docs", EntryKind::Dir, Attrs::default()),
5281                upsert("docs/guide.md", EntryKind::File, attrs(300, 30)),
5282                upsert("build", EntryKind::Dir, Attrs::default()),
5283                upsert("build/cache", EntryKind::Dir, Attrs::default()),
5284                upsert("build/cache/out.bin", EntryKind::File, attrs(1_000, 60)),
5285            ]))
5286            .expect("apply");
5287        index
5288    }
5289
5290    #[test]
5291    fn tree_entry_classification_is_independent_of_selected_subtree_tallies() {
5292        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
5293        index.apply_ok(&Observation::new(vec![
5294            Op::ControlUpsert {
5295                path: PathBuf::from(CONTROL),
5296                source: b"empty/\nzero.txt\n*.log\n".to_vec(),
5297            },
5298            upsert("empty", EntryKind::Dir, Attrs::default()),
5299            upsert("zero.txt", EntryKind::File, attrs(0, 0)),
5300            upsert("mixed", EntryKind::Dir, Attrs::default()),
5301            upsert("mixed/ignored.log", EntryKind::File, attrs(5, 5)),
5302            upsert("mixed/ordinary.rs", EntryKind::File, attrs(7, 7)),
5303        ]));
5304        let selection = |ignored| Selection {
5305            ignored,
5306            depth: Some(Bound::All),
5307            breadth: Some(Bound::All),
5308            limit: Some(Bound::All),
5309            min_share: Some(ShareThreshold::parse("0%").expect("share")),
5310            ..Selection::default()
5311        };
5312        let included =
5313            tree_of(&run(&index, &query(&[ViewSpec::Tree], selection(IgnoredEntries::Include))));
5314        assert_eq!(included.entry_ignored, Some(false));
5315        let child = |name: &str| included.children.iter().find(|row| row.name == name).expect(name);
5316        let empty = child("empty");
5317        assert_eq!((empty.kind, empty.bytes, empty.entry_ignored), (EntryKind::Dir, 0, Some(true)));
5318        assert_eq!(child("zero.txt").entry_ignored, Some(true));
5319        let mixed = child("mixed");
5320        assert_eq!(mixed.entry_ignored, Some(false));
5321        assert_eq!(mixed.ignored.expect("known").bytes, 5);
5322        assert_eq!(
5323            mixed
5324                .children
5325                .iter()
5326                .find(|row| row.name == "ignored.log")
5327                .expect("ignored leaf")
5328                .entry_ignored,
5329            Some(true)
5330        );
5331
5332        let only =
5333            tree_of(&run(&index, &query(&[ViewSpec::Tree], selection(IgnoredEntries::Only))));
5334        assert_eq!(only.entry_ignored, Some(false));
5335        let mixed_only = only.children.iter().find(|row| row.name == "mixed").expect("ancestor");
5336        assert_eq!(mixed_only.entry_ignored, Some(false));
5337        assert_eq!(mixed_only.ignored.expect("known").bytes, 5);
5338
5339        let mut unobserved =
5340            Index::new_with_scope("/root", crate::test_support::not_observing_controls());
5341        unobserved.apply_ok(&Observation::new(vec![upsert(
5342            "plain",
5343            EntryKind::Dir,
5344            Attrs::default(),
5345        )]));
5346        let unknown = tree_of(&run(
5347            &unobserved,
5348            &query(&[ViewSpec::Tree], selection(IgnoredEntries::Include)),
5349        ));
5350        assert_eq!(unknown.entry_ignored, None);
5351        assert_eq!(unknown.children[0].entry_ignored, None);
5352    }
5353
5354    #[test]
5355    fn ignored_size_interpretation_note_appears_once_only_when_relevant() {
5356        let classified = classified_sample();
5357        for (population, expected) in
5358            [(IgnoredEntries::Include, 1), (IgnoredEntries::Exclude, 0), (IgnoredEntries::Only, 0)]
5359        {
5360            let report = run(
5361                &classified,
5362                &query(
5363                    &[ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions],
5364                    Selection {
5365                        ignored: population,
5366                        depth: Some(Bound::All),
5367                        min_share: Some(ShareThreshold::parse("0%").expect("share")),
5368                        ..Selection::default()
5369                    },
5370                ),
5371            );
5372            let notes = crate::report_format::report_notes(&report);
5373            assert_eq!(
5374                notes.iter().filter(|note| note.contains("gitignored sizes are included")).count(),
5375                expected,
5376                "{population:?}: {notes:?}"
5377            );
5378        }
5379        let mut unobserved =
5380            Index::new_with_scope("/root", crate::test_support::not_observing_controls());
5381        unobserved.apply_ok(&Observation::new(vec![upsert(
5382            "plain.rs",
5383            EntryKind::File,
5384            attrs(10, 0),
5385        )]));
5386        let blind =
5387            run(&unobserved, &query(&[ViewSpec::Summary, ViewSpec::Tree], Selection::default()));
5388        assert!(
5389            crate::report_format::report_notes(&blind)
5390                .iter()
5391                .all(|note| !note.contains("gitignored sizes are included"))
5392        );
5393    }
5394
5395    #[test]
5396    fn tree_remainder_counts_only_unrepresented_root_children() {
5397        let index = classified_sample();
5398        let remainder = |selection| {
5399            let report = run(&index, &query(&[ViewSpec::Tree], selection));
5400            let Section::Tree { root, omissions, .. } = &report.sections[0] else {
5401                panic!("expected tree")
5402            };
5403            TreeRemainder::from_tree(root.as_deref(), omissions).expect("hidden rows")
5404        };
5405        let mixed = remainder(Selection {
5406            size: SizeMetric::Apparent,
5407            depth: Some(Bound::Limit(1)),
5408            breadth: Some(Bound::Limit(2)),
5409            min_share: Some(ShareThreshold::parse("5%").expect("share")),
5410            ..Selection::default()
5411        });
5412        let usage = |ignored: Option<IgnoredSize>| ignored.map(|part| (part.bytes, part.allocated));
5413        assert_eq!(
5414            (mixed.files, mixed.bytes, usage(mixed.ignored)),
5415            (Some(1), Some(300), Some((0, 0)))
5416        );
5417        assert_eq!(mixed.reasons, vec![TreeOmissionReason::Breadth]);
5418
5419        for limit in [Bound::Limit(1), Bound::Limit(0)] {
5420            let rows = remainder(Selection { limit: Some(limit), ..Selection::default() });
5421            assert_eq!((rows.bytes, usage(rows.ignored)), (Some(1_625), Some((1_025, 1_536))));
5422        }
5423        let excluded = remainder(Selection {
5424            depth: Some(Bound::Limit(0)),
5425            ignored: IgnoredEntries::Exclude,
5426            ..Selection::default()
5427        });
5428        assert_eq!((excluded.bytes, excluded.ignored), (Some(600), Some(IgnoredSize::default())));
5429        let only = remainder(Selection {
5430            depth: Some(Bound::Limit(0)),
5431            ignored: IgnoredEntries::Only,
5432            ..Selection::default()
5433        });
5434        assert_eq!((only.bytes, usage(only.ignored)), (Some(1_025), Some((1_025, 1_536))));
5435    }
5436
5437    #[test]
5438    fn colored_tree_populations_share_one_golden() {
5439        let classified = classified_sample();
5440        let mut unobserved =
5441            Index::new_with_scope("/root", crate::test_support::not_observing_controls());
5442        unobserved.apply_ok(&Observation::new(vec![upsert(
5443            "plain.rs",
5444            EntryKind::File,
5445            attrs(10, 0),
5446        )]));
5447
5448        let root = tempfile::tempdir().expect("root");
5449        fs::create_dir(root.path().join("guarded")).expect("directory");
5450        fs::write(root.path().join("known.rs"), "K").expect("known file");
5451        fs::write(root.path().join("guarded/.gitignore"), "*.rs\n").expect("control");
5452        fs::write(root.path().join("guarded/uncertain.rs"), "U").expect("uncertain file");
5453        let config = crate::ScanConfig {
5454            control_limits: crate::control::ControlLimits {
5455                line_limit: Some(1),
5456                ..crate::control::ControlLimits::default()
5457            },
5458            ..crate::ScanConfig::default()
5459        };
5460        let (refused, _) = crate::scan::scan_into_index(root.path(), &config).expect("scan");
5461        assert_eq!(refused.ignored_classification(Path::new("known.rs")), Some(false));
5462        assert_eq!(refused.ignored_classification(Path::new("guarded/uncertain.rs")), None);
5463
5464        let selection = |ignored| Selection {
5465            size: SizeMetric::Apparent,
5466            depth: Some(Bound::Limit(0)),
5467            ignored,
5468            ..Selection::default()
5469        };
5470        let cases = [
5471            ("INCLUDE", &classified, selection(IgnoredEntries::Include)),
5472            (
5473                "INCLUDE EXPANDED",
5474                &classified,
5475                Selection {
5476                    depth: Some(Bound::Limit(2)),
5477                    min_share: Some(ShareThreshold::parse("0%").expect("share")),
5478                    ..selection(IgnoredEntries::Include)
5479                },
5480            ),
5481            ("EXCLUDE", &classified, selection(IgnoredEntries::Exclude)),
5482            ("ONLY", &classified, selection(IgnoredEntries::Only)),
5483            ("NO CONTROLS", &unobserved, selection(IgnoredEntries::Include)),
5484            ("REFUSED CONTROL", &refused, selection(IgnoredEntries::Include)),
5485        ];
5486        let mut actual = String::new();
5487        for (label, index, selection) in cases {
5488            let report = run(index, &query(&[ViewSpec::Tree], selection));
5489            actual.push_str(label);
5490            actual.push('\n');
5491            actual.push_str(
5492                &crate::report_format::render(&report, crate::report_format::Format::Text, true)
5493                    .expect("colored tree")
5494                    .replace('\u{1b}', "<ESC>"),
5495            );
5496            actual.push('\n');
5497        }
5498        assert_eq!(actual, include_str!("../../tests/golden/tree-populations.txt"));
5499    }
5500
5501    #[test]
5502    fn tree_remainder_ignored_share_is_unknown_without_control_observation() {
5503        let mut index =
5504            Index::new_with_scope("/root", crate::test_support::not_observing_controls());
5505        index.apply_ok(&Observation::new(vec![upsert("file", EntryKind::File, attrs(10, 0))]));
5506        let report = run(
5507            &index,
5508            &query(
5509                &[ViewSpec::Tree],
5510                Selection { depth: Some(Bound::Limit(0)), ..Selection::default() },
5511            ),
5512        );
5513        let Section::Tree { root, omissions, .. } = &report.sections[0] else {
5514            panic!("expected tree")
5515        };
5516        let hidden = TreeRemainder::from_tree(root.as_deref(), omissions).expect("hidden file");
5517        assert_eq!((hidden.bytes, hidden.ignored), (Some(10), None));
5518    }
5519
5520    fn ignored_of(row: &SummaryRow) -> IgnoredTally {
5521        row.ignored.expect("an observing index reports an ignored share")
5522    }
5523
5524    /// Both tiers report the same ignored share on every row kind that carries one: the
5525    /// unfiltered tier subtracts the maintained partitions, and the traversal tier counts
5526    /// the entries it admits.
5527    #[test]
5528    fn the_two_tiers_agree_on_the_ignored_share() {
5529        let index = classified_sample();
5530        let expected = IgnoredTally { files: 2, dirs: 2, bytes: 1_025, allocated: 1_024 + 512 };
5531        for selection in
5532            [Selection::default(), Selection { min_size: Some(0), ..Selection::default() }]
5533        {
5534            let unfiltered = selection.is_unfiltered();
5535            let summary = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection.clone())));
5536            assert_eq!(ignored_of(&summary), expected, "unfiltered: {unfiltered}");
5537
5538            let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection.clone())));
5539            assert_eq!(tree.ignored, Some(expected), "unfiltered: {unfiltered}");
5540            let child = |name: &str| {
5541                tree.children.iter().find(|node| node.name == name).expect(name).ignored
5542            };
5543            assert_eq!(
5544                child("build"),
5545                Some(IgnoredTally { files: 1, dirs: 1, bytes: 1_000, allocated: 1_024 }),
5546                "an ignored directory is wholly ignored below it, unfiltered: {unfiltered}"
5547            );
5548            assert_eq!(
5549                child("src"),
5550                Some(IgnoredTally { files: 1, dirs: 0, bytes: 25, allocated: 512 }),
5551                "unfiltered: {unfiltered}"
5552            );
5553            assert_eq!(child("docs"), Some(IgnoredTally::default()), "observed, nothing ignored");
5554
5555            let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], selection.clone())));
5556            let row = |extension: &str| {
5557                rows.iter().find(|row| row.extension == extension).expect(extension).ignored
5558            };
5559            assert_eq!(
5560                row(".log"),
5561                Some(IgnoredTally { files: 1, dirs: 0, bytes: 25, allocated: 512 }),
5562                "unfiltered: {unfiltered}"
5563            );
5564            assert_eq!(row(".rs"), Some(IgnoredTally::default()), "unfiltered: {unfiltered}");
5565
5566            let files = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
5567            let flag =
5568                |path: PathBuf| files.iter().find(|row| row.path == path).map(|row| row.ignored);
5569            assert_eq!(flag(PathBuf::from("build")), Some(Some(true)));
5570            assert_eq!(flag(PathBuf::from("src")), Some(Some(false)));
5571            assert_eq!(flag(["src", "debug.log"].iter().collect()), Some(Some(true)));
5572            assert_eq!(flag(["build", "cache", "out.bin"].iter().collect()), Some(Some(true)));
5573        }
5574    }
5575
5576    /// Excluding ignored entries and selecting only them split the tree into two parts that
5577    /// sum to it, and each sizes and ranks its rows by what it selected.
5578    #[test]
5579    fn ignored_entries_partition_the_tree_and_rank_by_what_they_select() {
5580        let index = classified_sample();
5581        // Apparent, so the ranking is by the distinct sizes the sample wrote rather than by
5582        // the 512-byte blocks they round up to.
5583        let apparent = Selection { size: SizeMetric::Apparent, ..Selection::default() };
5584        let with = |ignored| Selection { ignored, ..apparent.clone() };
5585        let summary = |selection| summary_of(&run(&index, &query(&[ViewSpec::Summary], selection)));
5586        let total = summary(apparent.clone());
5587        let kept = summary(with(IgnoredEntries::Exclude));
5588        let only = summary(with(IgnoredEntries::Only));
5589        assert_eq!(
5590            (kept.files + only.files, kept.dirs + only.dirs, kept.bytes + only.bytes),
5591            (total.files, total.dirs, total.bytes)
5592        );
5593        assert_eq!(ignored_of(&kept), IgnoredTally::default());
5594        let whole = ignored_of(&only);
5595        assert_eq!((whole.files, whole.dirs, whole.bytes), (only.files, only.dirs, only.bytes));
5596
5597        let ranked = |selection| {
5598            tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)))
5599                .children
5600                .iter()
5601                .map(|node| (node.name.clone(), node.bytes))
5602                .collect::<Vec<_>>()
5603        };
5604        let row = |name: &str, bytes: u64| (name.to_string(), bytes);
5605        assert_eq!(
5606            ranked(apparent.clone()),
5607            [row("build", 1_000), row("src", 325), row("docs", 300)]
5608        );
5609        assert_eq!(
5610            ranked(with(IgnoredEntries::Exclude)),
5611            [row("docs", 300), row("src", 300)],
5612            "unignored sizes rank the rows, with the name breaking the tie"
5613        );
5614        assert_eq!(ranked(with(IgnoredEntries::Only)), [row("build", 1_000), row("src", 25)]);
5615    }
5616
5617    /// An index that read no rule reports no ignored share on any row, and refuses a
5618    /// selection by ignored state in the vocabulary of the surface that asked.
5619    #[test]
5620    fn an_index_that_observed_no_control_state_has_no_ignored_share_to_select_by() {
5621        let mut index =
5622            Index::new_with_scope("/root", crate::test_support::not_observing_controls());
5623        index
5624            .apply(&Observation::new(vec![
5625                upsert("build", EntryKind::Dir, Attrs::default()),
5626                upsert("build/out.bin", EntryKind::File, attrs(1_000, 60)),
5627            ]))
5628            .expect("apply");
5629        let views = [ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions, ViewSpec::Files];
5630        let report = run(&index, &query(&views, Selection::default()));
5631        let Section::Summary(summary) = &report.sections[0] else { panic!("a summary") };
5632        let Section::Tree { root: Some(tree), .. } = &report.sections[1] else { panic!("a tree") };
5633        let Section::Extensions { rows: extensions, .. } = &report.sections[2] else {
5634            panic!("extensions")
5635        };
5636        let Section::Files { rows: files, .. } = &report.sections[3] else { panic!("files") };
5637        assert_eq!(summary.ignored, None);
5638        assert_eq!(tree.ignored, None);
5639        assert!(tree.children.iter().all(|node| node.ignored.is_none()));
5640        assert!(extensions.iter().all(|row| row.ignored.is_none()));
5641        assert!(files.iter().all(|row| row.ignored.is_none()));
5642
5643        let exclude = Query {
5644            selection: Selection { ignored: IgnoredEntries::Exclude, ..Selection::default() },
5645            views: vec![ViewSpec::Summary],
5646            ..Query::default()
5647        };
5648        let only = Query {
5649            selection: Selection { ignored: IgnoredEntries::Only, ..Selection::default() },
5650            ..exclude.clone()
5651        };
5652        // The request model refuses such a request before it reaches a reader, in each
5653        // surface's words (`query_request`'s tests). The library path refuses it too, rather
5654        // than answering with no rows: a caller that reaches `report` without validating gets
5655        // the same typed refusal every other surface renders.
5656        for refused in [exclude, only] {
5657            assert!(
5658                matches!(
5659                    super::report(
5660                        &index,
5661                        &crate::test_support::read_of(&index, refused),
5662                        generated_at()
5663                    ),
5664                    Err(crate::Error::InvalidRequest(
5665                        crate::query::RequestError::IgnoredWithoutObservation(_)
5666                    ))
5667                ),
5668                "a selection by ignored state over an unobserving index is refused"
5669            );
5670        }
5671        assert_eq!(summary_of(&run(&index, &query(&views, Selection::default()))).files, 1);
5672    }
5673}