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