Skip to main content

fdu_core/
report_format.rs

1//! Serializing a [`Report`] to text, JSON, JSONL, and YAML.
2//!
3//! Formats are serializations, not features: every view renders in every format, so a
4//! caller picks the shape of the answer and the shape of the bytes independently.
5//!
6//! # Output design system
7//!
8//! Keep measured results and explanatory diagnostics separate. Renderers return only
9//! result data; frontends route the categorized messages from [`diagnostic_lines`] and
10//! [`report_warnings`] to their diagnostic stream. Machine formats must remain parseable
11//! and ANSI-free.
12//!
13//! Human rows use bright bold cyan names, ordinary foreground file counts, and gray
14//! parenthetical detail. Ignored amounts embedded in a row are always gray parentheses;
15//! file counts belong directly after the name, outside parentheses. Secondary breakdowns
16//! such as nonblank/blank counts use the same gray parenthetical role. Human directory
17//! names have a gray slash except `.` and `..`. Directly or ancestrally gitignored
18//! directories use regular, nonbold cyan; merely containing ignored files does not
19//! change a directory name, and file-name styling is unchanged. Sizes >= 1 GiB are bold even in gray
20//! details; zero sizes and exact shares below 1% are gray. Pad cells before applying ANSI styles.
21//! Colored bars use green solid non-gitignored and shaded gitignored usage, with dim green
22//! light-shade cells for unused width. Plain bars keep their original glyphs. Human tree bar width
23//! is caller-selectable, including zero to remove the bar and its gutter; machine
24//! formats and non-tree views ignore it. Human integer quantities share one grouping
25//! policy through [`human_count`] and [`human_count_u128`]. Every human byte quantity,
26//! including cache rows and lifecycle totals, uses [`styled_bytes`] for its units and
27//! zero/large-value emphasis. Machine fields retain exact integer bytes.
28//!
29//! A grouped row's first line ends at its file count; each further measure (lines, words,
30//! flags, coverage reasons) is a continuation line of its own, its size, share, and label
31//! cells blank and its text two columns right of the file count. See the layout rules
32//! beside `render_text_metrics`.
33//!
34//! Tree columns are bar, root percentage, size, then indented name. One remainder
35//! row per tree uses those same columns and quantity styles for unlisted root branches.
36//! Its `… and` prefix is gray; the recursive hidden file count uses normal foreground.
37//! That usage is already included in directory totals.
38//! Unknown coverage must show unknown size and no fabricated bar or percentage.
39//! Keep rerun flags out of rows: collect applicable remedies once per report in
40//! `report_epilogue`.
41//!
42//! The category, ordering, and debugging contract lives beside that collector; CLI
43//! stream/color handling lives in `write_report_diagnostics`. The contributor guide is
44//! `docs/project/architecture/fdu-output-design.md`. Changes must keep the shared golden
45//! corpus, Python parity, and terminal stream/color assertions consistent.
46//!
47//! # Why these are hand-written
48//!
49//! `serde` plus a JSON crate plus a YAML crate would be three dependency additions —
50//! and the maintained-YAML question is genuinely unsettled, since `serde_yaml` is
51//! unmaintained. The schema here is small, closed, and fully known at compile time, the
52//! crate already hand-writes its JSON, and hand-writing keeps the machine formats
53//! provably free of a serializer's own opinions about key order and number formatting.
54//! Key order is fixed by the code, which is what makes the goldens byte-stable.
55
56mod report_epilogue;
57
58pub use report_epilogue::{
59    DiagnosticLines, diagnostic_lines, diagnostics, report_notes, report_tips, report_warnings,
60};
61
62use std::fmt::Write as _;
63use std::io;
64use std::path::Path;
65
66use anstyle::{AnsiColor, Style as AnsiStyle};
67
68use crate::classify::human_language_name;
69use crate::content::{CoverageReason, METRICS};
70use crate::control::ControlCoverage;
71use crate::emit::{Event, IoFmt, JsonSink, Scalar, Shape, Sink, YamlSink};
72use crate::engine_contract::{Coverage, EntryKind, Freshness, IssueKind, Source};
73use crate::query::{
74    CodeOverview, CodeTally, FileRow, IgnoredEntries, IgnoredTally, MetricGroup, MetricRow,
75    MetricSummary, Report, ReportSource, Section, ShareMetric, SizeMetric, SummaryRow, TierState,
76    TreeNode, TypeRow, ViewSpec, format_rfc3339, format_rfc3339_nanos, pages,
77};
78
79/// The all-caps label naming which view a block of text output belongs to.
80///
81/// Bold cyan is what `cli.rs` already gives a section heading in `--help`, so a report
82/// and the help that describes it use one visual language for the same idea.
83/// View headers share the CLI's one header style; see `cli::STYLE_HEADING`.
84pub const STYLE_HEADING: AnsiStyle = AnsiColor::Cyan.on_default().bold();
85
86/// Directory names in a tree, so structure reads at a glance.
87pub const STYLE_NAME: AnsiStyle = AnsiColor::BrightCyan.on_default().bold();
88
89/// A directory whose own path is gitignored, directly or by an ignored ancestor.
90const STYLE_IGNORED_NAME: AnsiStyle = AnsiColor::Cyan.on_default();
91
92const STYLE_BAR: AnsiStyle = AnsiColor::Green.on_default();
93
94/// Category labels keep ordinary cyan; bold bright cyan identifies names.
95pub const STYLE_CATEGORY: AnsiStyle = AnsiColor::Cyan.on_default();
96
97/// Secondary information: parenthetical detail, omissions, notes, tips, and telemetry.
98/// Keep this gray and non-bold except for the shared >= 1 GiB size emphasis.
99pub const STYLE_DETAIL: AnsiStyle = AnsiColor::BrightBlack.on_default();
100
101/// Established label width for non-language metric summaries.
102const TEXT_METRIC_LABEL_WIDTH: usize = 18;
103/// Floor for the extensions view's label column.
104const TEXT_TYPE_LABEL_WIDTH: usize = 12;
105
106/// Machine-output schema identity.
107///
108/// Any change to a field's name, type, or meaning bumps this, and a golden test fails if
109/// the schema moves without it — the versioning is the promise, not the intention.
110pub const REPORT_SCHEMA: &str = "fdu.report/10";
111/// All reports now use one shape-versioned schema regardless of requested analyzers.
112pub const CONTENT_REPORT_SCHEMA: &str = REPORT_SCHEMA;
113/// Machine-output schema identity for cache status.
114///
115/// Its own identity because cache status is its own document: a fact about the cache
116/// directory rather than about a tree, which is why it is not a `Report` section. It
117/// carries the same promise as [`REPORT_SCHEMA`] and versions independently, so a change
118/// to the report shape never invalidates a cache-status consumer, or the reverse.
119///
120/// `fdu.cache/3` adds the identity of every tier a store holds: a current snapshot's
121/// `identity`, and a `content` object for the sidecar beside any snapshot, in place of
122/// `content_bytes`.
123pub const CACHE_SCHEMA: &str = "fdu.cache/3";
124
125/// How a report is serialized.
126#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
127pub enum Format {
128    /// Human-readable text.
129    #[default]
130    Text,
131    /// The bounded directory hierarchy for a list.
132    Tree,
133    /// Matching paths, one safely escaped path per line.
134    Paths,
135    /// Flat size, signed modification age, and path columns.
136    Long,
137    /// One JSON document.
138    Json,
139    /// One JSON document per line, one line per section.
140    Jsonl,
141    /// YAML.
142    Yaml,
143}
144
145/// Maximum tree bar width accepted by the human renderer.
146/// Bounds decoration allocation without changing measured report data.
147pub const MAX_BAR_SIZE: usize = 4096;
148
149/// Presentation choices for a human report; machine formats ignore both fields.
150#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub struct RenderOptions {
152    /// Whether to emit terminal color and emphasis.
153    pub color: bool,
154    /// Width of tree usage bars in cells; zero removes the bar and its gutter.
155    pub bar_size: usize,
156}
157
158impl Default for RenderOptions {
159    fn default() -> Self {
160        Self { color: false, bar_size: 10 }
161    }
162}
163
164/// Start one document in a multi-document stream for `format`.
165pub const fn document_start(format: Format) -> &'static str {
166    match format {
167        Format::Yaml => "---\n",
168        Format::Text
169        | Format::Tree
170        | Format::Paths
171        | Format::Long
172        | Format::Json
173        | Format::Jsonl => "",
174    }
175}
176
177impl Format {
178    /// Stable spelling used by request adapters and diagnostics.
179    pub const fn label(self) -> &'static str {
180        match self {
181            Self::Text => "text",
182            Self::Tree => "tree",
183            Self::Paths => "paths",
184            Self::Long => "long",
185            Self::Json => "json",
186            Self::Jsonl => "jsonl",
187            Self::Yaml => "yaml",
188        }
189    }
190
191    /// Whether this format is a structured serialization.
192    pub const fn is_machine(self) -> bool {
193        matches!(self, Self::Json | Self::Jsonl | Self::Yaml)
194    }
195
196    /// Parse a `--format` value.
197    pub fn parse(value: &str) -> Option<Self> {
198        match value.trim().to_ascii_lowercase().as_str() {
199            "text" => Some(Self::Text),
200            "tree" => Some(Self::Tree),
201            "paths" => Some(Self::Paths),
202            "long" => Some(Self::Long),
203            "json" => Some(Self::Json),
204            "jsonl" => Some(Self::Jsonl),
205            "yaml" => Some(Self::Yaml),
206            _ => None,
207        }
208    }
209
210    /// Every accepted spelling, for help text and error messages.
211    pub const ALL: &'static [&'static str] =
212        &["text", "tree", "paths", "long", "json", "jsonl", "yaml"];
213}
214
215/// Render a report in the requested format.
216///
217/// `color` applies to the text form only: machine output is never colourized, because a
218/// consumer parsing JSON should never have to strip escape sequences first.
219///
220/// # Errors
221///
222/// Returns an invalid-request error when Tree/Paths/Long cannot represent the stored
223/// projection. Request the desired format on the query before reading: a detached,
224/// folded tree does not retain the complete flat inventory.
225pub fn render(report: &Report, format: Format, color: bool) -> crate::Result<String> {
226    render_with_options(report, format, RenderOptions { color, ..RenderOptions::default() })
227}
228
229/// Render with explicit human presentation choices.
230///
231/// Machine and flat formats retain their existing bytes regardless of `bar_size`.
232///
233/// # Errors
234///
235/// Returns an invalid-request error for an incompatible format or a human tree bar wider
236/// than [`MAX_BAR_SIZE`].
237pub fn render_with_options(
238    report: &Report,
239    format: Format,
240    options: RenderOptions,
241) -> crate::Result<String> {
242    let format = checked_format(report, format)?;
243    checked_bar_size(report, format, options)?;
244    Ok(match format {
245        Format::Text | Format::Tree => render_text(report, options),
246        Format::Paths | Format::Long => render_flat(report, format),
247        Format::Json => render_report_machine(report, true, JsonSink::pretty()),
248        Format::Jsonl => render_report_jsonl(report),
249        Format::Yaml => render_report_machine(report, true, YamlSink::new()),
250    })
251}
252
253/// One path for a line-oriented listing: control characters become escapes so a row stays
254/// one row, and everything else, the separator included, is written as it is.
255///
256/// Only control characters. This once escaped `\` as well, and on Windows the separator
257/// *is* `\`, so `--format paths` printed `c\\target`, a path that does not exist, and the
258/// golden that covered it matched the doubled separator instead of failing on it. The
259/// price of not escaping it is that a name holding a literal backslash followed by a
260/// letter is ambiguous with an escape; the listing is lossy by contract, and a consumer
261/// that needs byte identity reads JSON's `path_raw`.
262fn flat_path(path: &Path) -> String {
263    path.to_string_lossy()
264        .chars()
265        .flat_map(|c| if c.is_control() { c.escape_default().collect::<Vec<_>>() } else { vec![c] })
266        .collect()
267}
268
269/// A compact signed duration. Exact nanoseconds remain available in machine output.
270fn human_age(age: Option<i128>) -> String {
271    let Some(age) = age else { return "unknown".to_string() };
272    let seconds = age.unsigned_abs() / 1_000_000_000;
273    let (amount, unit) = if seconds >= 86400 {
274        (seconds / 86400, "d")
275    } else if seconds >= 3600 {
276        (seconds / 3600, "h")
277    } else if seconds >= 60 {
278        (seconds / 60, "m")
279    } else {
280        (seconds, "s")
281    };
282    format!("{}{}{unit}", if age < 0 { "-" } else { "" }, human_count_u128(amount))
283}
284
285/// Notes, the report's warnings, and tips excluded from flat stdout, for a frontend's
286/// diagnostic stream.
287pub fn flat_diagnostics(report: &Report) -> Vec<String> {
288    report_epilogue::with_warnings(report, flat_diagnostic_lines(report))
289}
290
291/// Categorized flat-output diagnostics; paths and long rows stay alone on stdout.
292///
293/// The row bound is already a display-limit note: a flat report has one section.
294///
295/// A cache-only answer is stated by [`report_warnings`], on every format, so it is not
296/// repeated here as a note.
297pub fn flat_diagnostic_lines(report: &Report) -> DiagnosticLines {
298    let DiagnosticLines { mut notes, tips } = diagnostic_lines(report);
299    if !report.status.complete || report.provenance.freshness != Freshness::Fresh {
300        notes.push(format!(
301            "note: result {}, {}",
302            freshness_label(report.provenance.freshness),
303            if report.status.complete { "complete" } else { "incomplete" }
304        ));
305    }
306    if let Some(depth) = report.scope.max_depth {
307        notes.push(format!(
308            "note: scanned to depth {} only; totals cover that scope",
309            human_count_u128(depth as u128)
310        ));
311    }
312    DiagnosticLines { notes, tips }
313}
314
315fn render_flat(report: &Report, format: Format) -> String {
316    let mut out = String::new();
317    for section in &report.sections {
318        if let Section::Files { rows, .. } = section {
319            for row in rows {
320                if format == Format::Long {
321                    let _ = writeln!(
322                        out,
323                        "{:>10} {:>8} {}",
324                        human_bytes(pick(report.size, row.bytes, row.allocated)),
325                        human_age(row.age_ns),
326                        flat_path(&row.path)
327                    );
328                } else {
329                    let _ = writeln!(out, "{}", flat_path(&row.path));
330                }
331            }
332        }
333    }
334    out
335}
336
337fn checked_format(report: &Report, format: Format) -> crate::Result<Format> {
338    let format = if format == Format::Text { report.format } else { format };
339    let valid = match format {
340        Format::Paths | Format::Long => {
341            report.sections.len() == 1 && matches!(report.sections[0], Section::Files { .. })
342        }
343        Format::Tree => {
344            report.sections.len() == 1 && matches!(report.sections[0], Section::Tree { .. })
345        }
346        Format::Text | Format::Json | Format::Jsonl | Format::Yaml => true,
347    };
348    if !valid {
349        return Err(crate::Error::InvalidRequest(crate::query::Rejection::new(format.label(),
350            "incompatible with this report projection; request the desired format when building the query (a folded tree cannot become a complete flat list)").on("format")));
351    }
352    Ok(format)
353}
354
355fn checked_bar_size(report: &Report, format: Format, options: RenderOptions) -> crate::Result<()> {
356    if matches!(format, Format::Text | Format::Tree)
357        && report.sections.iter().any(|section| matches!(section, Section::Tree { .. }))
358        && options.bar_size > MAX_BAR_SIZE
359    {
360        return Err(crate::Error::InvalidRequest(
361            crate::query::Rejection::new(
362                options.bar_size.to_string(),
363                format!("at most {MAX_BAR_SIZE} cells"),
364            )
365            .on("bar_size"),
366        ));
367    }
368    Ok(())
369}
370
371/// Write a report directly to an output stream.
372///
373/// Machine formats retain only serializer depth while walking the report. Text remains a
374/// presentation renderer and is written after it is formatted.
375pub fn write(
376    report: &Report,
377    format: Format,
378    color: bool,
379    out: &mut dyn io::Write,
380) -> io::Result<()> {
381    write_with_options(report, format, RenderOptions { color, ..RenderOptions::default() }, out)
382}
383
384/// Write with explicit human presentation choices.
385///
386/// Machine and flat formats retain their existing bytes regardless of `bar_size`.
387///
388/// # Errors
389///
390/// Returns an I/O error when writing fails, the format is incompatible with the report,
391/// or a human tree bar exceeds [`MAX_BAR_SIZE`].
392pub fn write_with_options(
393    report: &Report,
394    format: Format,
395    options: RenderOptions,
396    out: &mut dyn io::Write,
397) -> io::Result<()> {
398    let format = checked_format(report, format)
399        .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
400    checked_bar_size(report, format, options)
401        .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
402    match format {
403        Format::Text | Format::Tree => out.write_all(render_text(report, options).as_bytes()),
404        Format::Paths | Format::Long => out.write_all(render_flat(report, format).as_bytes()),
405        Format::Json => write_report_machine(report, true, JsonSink::pretty_to(out)),
406        Format::Jsonl => write_report_jsonl(report, out),
407        Format::Yaml => write_report_machine(report, true, YamlSink::to(out)),
408    }
409}
410
411fn render_report_machine(
412    report: &Report,
413    with_sections: bool,
414    mut sink: impl Sink<Output = String>,
415) -> String {
416    emit_report(&mut sink, report, with_sections);
417    sink.finish()
418}
419
420fn write_report_machine<'a>(
421    report: &Report,
422    with_sections: bool,
423    mut sink: impl Sink<Output = IoFmt<'a>>,
424) -> io::Result<()> {
425    emit_report(&mut sink, report, with_sections);
426    sink.finish().finish()
427}
428
429fn render_report_jsonl(report: &Report) -> String {
430    let mut sink = JsonSink::line();
431    emit_report(&mut sink, report, false);
432    let mut out = sink.finish();
433    out.push('\n');
434    for section in &report.sections {
435        let mut sink = JsonSink::line();
436        emit_section(&mut sink, section);
437        out.push_str(&sink.finish());
438        out.push('\n');
439    }
440    out
441}
442
443fn write_report_jsonl(report: &Report, out: &mut dyn io::Write) -> io::Result<()> {
444    let mut sink = JsonSink::line_to(out);
445    emit_report(&mut sink, report, false);
446    sink.finish().finish()?;
447    out.write_all(b"\n")?;
448    for section in &report.sections {
449        let mut sink = JsonSink::line_to(out);
450        emit_section(&mut sink, section);
451        sink.finish().finish()?;
452        out.write_all(b"\n")?;
453    }
454    Ok(())
455}
456
457fn emit_field<S: Sink>(sink: &mut S, field: Field, condition: bool, emit: impl FnOnce(&mut S)) {
458    let present = match field.presence {
459        Presence::Always | Presence::Nullable => true,
460        Presence::WhenAnalyzer(_) => {
461            panic!("an analyzer-owned field must use emit_analyzer_field")
462        }
463        Presence::WhenLossy | Presence::WhenSet => condition,
464    };
465    if present {
466        sink.event(Event::Key(field.name));
467        emit(sink);
468    }
469}
470
471fn emit_analyzer_field<S: Sink>(
472    sink: &mut S,
473    requested: crate::content::AnalysisSet,
474    field: Field,
475    emit: impl FnOnce(&mut S),
476) {
477    let Presence::WhenAnalyzer(owner) = field.presence else {
478        panic!("an analyzer field must declare its owning unit");
479    };
480    if requested.contains(owner) {
481        sink.event(Event::Key(field.name));
482        emit(sink);
483    }
484}
485
486fn emit_scalar(sink: &mut impl Sink, value: Scalar<'_>) {
487    sink.event(Event::Scalar(value));
488}
489
490fn emit_report(sink: &mut impl Sink, report: &Report, with_sections: bool) {
491    sink.event(Event::BeginMap(Shape::Block));
492    emit_field(sink, REPORT_FIELDS.schema, true, |sink| {
493        emit_scalar(sink, Scalar::Str(REPORT_SCHEMA));
494    });
495    let generator = generator();
496    emit_field(sink, REPORT_FIELDS.generator, true, |sink| {
497        emit_scalar(sink, Scalar::Str(&generator));
498    });
499    let root = report.root.to_string_lossy();
500    emit_field(sink, REPORT_FIELDS.root, true, |sink| {
501        emit_scalar(sink, Scalar::Str(&root));
502    });
503    emit_raw_identity(sink, REPORT_FIELDS.root_raw.name, &report.root);
504    emit_field(sink, REPORT_FIELDS.age_reference_ns, true, |sink| match report.age_reference_ns {
505        Some(value) => emit_scalar(sink, Scalar::I64(value)),
506        None => emit_scalar(sink, Scalar::Null),
507    });
508    emit_field(sink, REPORT_FIELDS.request, true, |sink| emit_request(sink, report));
509    emit_field(sink, REPORT_FIELDS.status, true, |sink| emit_status(sink, report));
510    emit_field(sink, REPORT_FIELDS.provenance, true, |sink| emit_provenance(sink, report));
511    emit_field(sink, REPORT_FIELDS.ignore_rules, true, |sink| {
512        emit_ignore_rules(sink, &report.ignore_rules);
513    });
514    emit_field(sink, REPORT_FIELDS.analysis, true, |sink| {
515        emit_analysis(sink, report.analysis.as_ref());
516    });
517    emit_field(sink, REPORT_FIELDS.reports, with_sections, |sink| {
518        sink.event(Event::BeginSeq(Shape::Block));
519        for section in &report.sections {
520            emit_section(sink, section);
521        }
522        sink.event(Event::EndSeq);
523    });
524    sink.event(Event::EndMap);
525}
526
527fn emit_request(sink: &mut impl Sink, report: &Report) {
528    sink.event(Event::BeginMap(Shape::Block));
529    emit_field(sink, Field::always("scope"), true, |sink| {
530        sink.event(Event::BeginMap(Shape::Block));
531        emit_field(sink, Field::nullable("max_depth"), true, |sink| {
532            emit_optional_usize(sink, report.scope.max_depth);
533        });
534        emit_bool_field(sink, "follow_symlinks", report.scope.follow_symlinks);
535        emit_bool_field(sink, "one_filesystem", report.scope.one_filesystem);
536        emit_bool_field(sink, "exclude_special", report.scope.exclude_special);
537        emit_bool_field(sink, "read_controls", report.scope.observes_controls());
538        emit_str_field(sink, "population", report.scope.population.label());
539        sink.event(Event::EndMap);
540    });
541    emit_field(sink, Field::always("analyze"), true, |sink| {
542        sink.event(Event::BeginSeq(Shape::Inline));
543        for label in analysis_set_labels(report.requested_analysis) {
544            emit_scalar(sink, Scalar::Str(label));
545        }
546        sink.event(Event::EndSeq);
547    });
548    emit_str_field(sink, "size", report.size.label());
549    emit_field(sink, Field::nullable("sort_metric"), true, |sink| match report.sort_metric {
550        Some(name) => emit_scalar(sink, Scalar::Str(name)),
551        None => emit_scalar(sink, Scalar::Null),
552    });
553    emit_field(sink, Field::always("views"), true, |sink| {
554        sink.event(Event::BeginSeq(Shape::Inline));
555        for view in &report.requested_views {
556            emit_scalar(sink, Scalar::Str(view.label()));
557        }
558        sink.event(Event::EndSeq);
559    });
560    emit_field(sink, Field::always("omitted_views"), true, |sink| {
561        sink.event(Event::BeginSeq(Shape::Inline));
562        for view in &report.omitted_views {
563            emit_scalar(sink, Scalar::Str(view.label()));
564        }
565        sink.event(Event::EndSeq);
566    });
567    sink.event(Event::EndMap);
568}
569
570fn emit_status(sink: &mut impl Sink, report: &Report) {
571    sink.event(Event::BeginMap(Shape::Block));
572    emit_bool_field(sink, "complete", report.status.complete);
573    emit_field(sink, Field::always("coverage"), true, |sink| {
574        sink.event(Event::BeginMap(Shape::Inline));
575        match report.status.coverage {
576            Coverage::Complete => emit_str_field(sink, "kind", "complete"),
577            Coverage::Partial(reason) => {
578                emit_str_field(sink, "kind", "partial");
579                emit_str_field(sink, "reason", structural_coverage_label(reason));
580            }
581        }
582        sink.event(Event::EndMap);
583    });
584    emit_field(sink, Field::always("errors"), true, |sink| {
585        sink.event(Event::BeginSeq(Shape::Block));
586        for error in &report.status.errors {
587            sink.event(Event::BeginMap(Shape::Block));
588            emit_field(sink, Field::when_set("path"), error.path.is_some(), |sink| {
589                let path = error.path.as_ref().expect("present error path");
590                let display = path.to_string_lossy();
591                emit_scalar(sink, Scalar::Str(&display));
592            });
593            if let Some(path) = &error.path {
594                emit_raw_identity(sink, "path_raw", path);
595            }
596            emit_str_field(sink, "kind", issue_kind_label(error.kind));
597            emit_str_field(sink, "message", &error.message);
598            emit_field(sink, Field::when_set("os_error"), error.os_error.is_some(), |sink| {
599                emit_scalar(sink, Scalar::I64(i64::from(error.os_error.expect("present errno"))));
600            });
601            sink.event(Event::EndMap);
602        }
603        sink.event(Event::EndSeq);
604    });
605    emit_u64_field(sink, "errors_omitted", report.status.errors_omitted);
606    sink.event(Event::EndMap);
607}
608
609fn emit_provenance(sink: &mut impl Sink, report: &Report) {
610    sink.event(Event::BeginMap(Shape::Block));
611    emit_str_field(sink, "source", source_label(report.provenance.source));
612    emit_str_field(sink, "freshness", freshness_label(report.provenance.freshness));
613    emit_field(sink, Field::nullable("scan_started_at"), true, |sink| {
614        if let Some(at) = report.provenance.scan_started_at {
615            let value = format_rfc3339(at);
616            emit_scalar(sink, Scalar::Str(&value));
617        } else {
618            emit_scalar(sink, Scalar::Null);
619        }
620    });
621    let generated_at = format_rfc3339(report.provenance.generated_at);
622    emit_str_field(sink, "generated_at", &generated_at);
623    emit_field(sink, Field::always("tiers"), true, |sink| {
624        sink.event(Event::BeginMap(Shape::Block));
625        emit_field(sink, Field::always("entries"), true, |sink| {
626            emit_tier_state(sink, report.provenance.tiers.entries);
627        });
628        emit_field(sink, Field::nullable("content"), true, |sink| {
629            if let Some(content) = report.provenance.tiers.content {
630                emit_tier_state(sink, content);
631            } else {
632                emit_scalar(sink, Scalar::Null);
633            }
634        });
635        sink.event(Event::EndMap);
636    });
637    sink.event(Event::EndMap);
638}
639
640fn emit_tier_state(sink: &mut impl Sink, tier: TierState) {
641    sink.event(Event::BeginMap(Shape::Inline));
642    emit_str_field(sink, "source", tier_source_label(tier.source));
643    emit_str_field(sink, "freshness", freshness_label(tier.freshness));
644    emit_field(sink, Field::nullable("observed_at_ns"), true, |sink| match tier.observed_at_ns {
645        Some(value) => emit_scalar(sink, Scalar::I64(value)),
646        None => emit_scalar(sink, Scalar::Null),
647    });
648    sink.event(Event::EndMap);
649}
650
651fn emit_ignore_rules(sink: &mut impl Sink, rules: &ControlCoverage) {
652    let ControlCoverage::Observed(observed) = rules else {
653        emit_scalar(sink, Scalar::Null);
654        return;
655    };
656    sink.event(Event::BeginMap(Shape::Block));
657    emit_field(sink, Field::always("limits"), true, |sink| {
658        sink.event(Event::BeginMap(Shape::Inline));
659        emit_field(sink, Field::nullable("budget"), true, |sink| {
660            emit_optional_usize(sink, observed.limits.budget);
661        });
662        emit_field(sink, Field::nullable("line_limit"), true, |sink| {
663            emit_optional_usize(sink, observed.limits.line_limit);
664        });
665        sink.event(Event::EndMap);
666    });
667    emit_u64_field(sink, "applied", observed.applied);
668    emit_u64_field(sink, "rules", observed.rules);
669    emit_u64_field(sink, "refused", observed.refused);
670    emit_field(sink, Field::always("refusals"), true, |sink| {
671        sink.event(Event::BeginSeq(Shape::Block));
672        for refusal in &observed.refusals {
673            sink.event(Event::BeginMap(Shape::Block));
674            emit_path_fields(sink, &refusal.path);
675            emit_str_field(sink, "reason", refusal.reason.label());
676            sink.event(Event::EndMap);
677        }
678        sink.event(Event::EndSeq);
679    });
680    sink.event(Event::EndMap);
681}
682
683fn emit_analysis(sink: &mut impl Sink, analysis: Option<&crate::query::ContentReportMetadata>) {
684    let Some(analysis) = analysis else {
685        emit_scalar(sink, Scalar::Null);
686        return;
687    };
688    sink.event(Event::BeginMap(Shape::Block));
689    emit_field(sink, Field::always("analyze"), true, |sink| {
690        sink.event(Event::BeginSeq(Shape::Inline));
691        for label in analysis_set_labels(analysis.profile) {
692            emit_scalar(sink, Scalar::Str(label));
693        }
694        sink.event(Event::EndSeq);
695    });
696    emit_u64_field(sink, "type_rules_fingerprint", analysis.provenance.type_rules_fingerprint);
697    emit_u64_field(sink, "options_fingerprint", analysis.provenance.options_fingerprint.0);
698    emit_field(sink, Field::always("analyzers"), true, |sink| {
699        sink.event(Event::BeginSeq(Shape::Block));
700        for (id, version) in &analysis.provenance.analyzers {
701            sink.event(Event::BeginMap(Shape::Inline));
702            emit_str_field(sink, "id", id.0);
703            emit_u64_field(sink, "version", u64::from(version.0));
704            sink.event(Event::EndMap);
705        }
706        sink.event(Event::EndSeq);
707    });
708    sink.event(Event::EndMap);
709}
710
711fn emit_optional_usize(sink: &mut impl Sink, value: Option<usize>) {
712    match value {
713        Some(value) => emit_scalar(sink, Scalar::U64(value as u64)),
714        None => emit_scalar(sink, Scalar::Null),
715    }
716}
717
718fn emit_str_field(sink: &mut impl Sink, name: &'static str, value: &str) {
719    emit_field(sink, Field::always(name), true, |sink| {
720        emit_scalar(sink, Scalar::Str(value));
721    });
722}
723
724fn emit_u64_field(sink: &mut impl Sink, name: &'static str, value: u64) {
725    emit_field(sink, Field::always(name), true, |sink| {
726        emit_scalar(sink, Scalar::U64(value));
727    });
728}
729
730fn emit_bool_field(sink: &mut impl Sink, name: &'static str, value: bool) {
731    emit_field(sink, Field::always(name), true, |sink| {
732        emit_scalar(sink, Scalar::Bool(value));
733    });
734}
735
736fn emit_i64_field(sink: &mut impl Sink, name: &'static str, value: i64) {
737    emit_field(sink, Field::always(name), true, |sink| {
738        emit_scalar(sink, Scalar::I64(value));
739    });
740}
741
742fn emit_raw_identity(sink: &mut impl Sink, name: &'static str, path: &Path) {
743    let raw = raw_os_identity(path.as_os_str());
744    emit_field(sink, Field::when_lossy(name), raw.is_some(), |sink| {
745        let (encoding, hex) = raw.expect("lossy field predicate checked the raw identity");
746        sink.event(Event::BeginMap(Shape::Inline));
747        emit_str_field(sink, "encoding", encoding);
748        emit_str_field(sink, "hex", &hex);
749        sink.event(Event::EndMap);
750    });
751}
752
753fn emit_path_fields(sink: &mut impl Sink, path: &Path) {
754    let lossy = path.to_string_lossy();
755    emit_str_field(sink, "path", &lossy);
756    emit_raw_identity(sink, "path_raw", path);
757}
758
759fn emit_section(sink: &mut impl Sink, section: &Section) {
760    sink.event(Event::BeginMap(Shape::Block));
761    emit_str_field(sink, "view", section.view().label());
762    match section {
763        Section::Code(overview) => {
764            emit_field(sink, Field::always("code"), true, |sink| {
765                emit_code_overview(sink, overview);
766            });
767        }
768        Section::Tree { root, omissions, limits, .. } => {
769            sink.event(Event::Key("limits"));
770            sink.event(Event::BeginMap(Shape::Inline));
771            emit_bound_value(sink, "depth", limits.depth);
772            emit_str_field(sink, "min_share", &limits.min_share.label());
773            emit_bound_value(sink, "breadth", limits.breadth);
774            emit_bound_value(sink, "rows", limits.rows);
775            sink.event(Event::EndMap);
776            emit_field(sink, Field::always("tree"), true, |sink| match root {
777                Some(root) => emit_tree(sink, root),
778                None => emit_scalar(sink, Scalar::Null),
779            });
780            emit_tree_omissions(sink, omissions);
781            sink.event(Event::Key("remainder"));
782            match crate::query::TreeRemainder::from_tree(root.as_deref(), omissions) {
783                Some(remainder) => {
784                    sink.event(Event::BeginMap(Shape::Block));
785                    for (key, value) in [
786                        ("files", remainder.files),
787                        ("bytes", remainder.bytes),
788                        ("allocated", remainder.allocated),
789                    ] {
790                        sink.event(Event::Key(key));
791                        emit_scalar(sink, value.map_or(Scalar::Null, Scalar::U64));
792                    }
793                    sink.event(Event::Key("reasons"));
794                    sink.event(Event::BeginSeq(Shape::Inline));
795                    for reason in remainder.reasons {
796                        emit_scalar(sink, Scalar::Str(reason.label()));
797                    }
798                    sink.event(Event::EndSeq);
799                    sink.event(Event::EndMap);
800                }
801                None => emit_scalar(sink, Scalar::Null),
802            }
803        }
804        Section::Extensions { rows, total, share_omitted } => {
805            emit_bound_field(sink, rows.len(), *total);
806            emit_u64_field(sink, "share_omitted", *share_omitted as u64);
807            emit_field(sink, Field::always("extensions"), true, |sink| {
808                sink.event(Event::BeginSeq(Shape::Block));
809                for row in rows {
810                    sink.event(Event::BeginMap(Shape::Block));
811                    emit_str_field(sink, "extension", &row.extension);
812                    emit_u64_field(sink, "files", row.files);
813                    emit_u64_field(sink, "bytes", row.bytes);
814                    emit_u64_field(sink, "allocated", row.allocated);
815                    emit_field(sink, Field::nullable("ignored"), true, |sink| {
816                        emit_ignored(sink, row.ignored, false);
817                    });
818                    sink.event(Event::EndMap);
819                }
820                sink.event(Event::EndSeq);
821            });
822        }
823        Section::Metrics { summary, .. } => {
824            emit_field(sink, Field::always("metrics"), true, |sink| {
825                emit_metric_summary(sink, summary);
826            });
827        }
828        Section::Files { rows, total, .. } => {
829            emit_bound_field(sink, rows.len(), *total);
830            emit_field(sink, Field::always("files"), true, |sink| {
831                sink.event(Event::BeginSeq(Shape::Block));
832                for row in rows {
833                    emit_file_row(sink, row);
834                }
835                sink.event(Event::EndSeq);
836            });
837        }
838        Section::Summary(row) => {
839            emit_field(sink, Field::always("summary"), true, |sink| {
840                emit_summary_row(sink, row);
841            });
842        }
843    }
844    sink.event(Event::EndMap);
845}
846
847fn emit_bound_value(sink: &mut impl Sink, field: &'static str, bound: crate::query::Bound) {
848    sink.event(Event::Key(field));
849    match bound {
850        crate::query::Bound::All => emit_scalar(sink, Scalar::Null),
851        crate::query::Bound::Limit(value) => emit_scalar(sink, Scalar::U64(value as u64)),
852    }
853}
854
855fn emit_bound_field(sink: &mut impl Sink, shown: usize, total: usize) {
856    emit_field(sink, Field::nullable("bound"), true, |sink| {
857        if shown >= total {
858            emit_scalar(sink, Scalar::Null);
859        } else {
860            sink.event(Event::BeginMap(Shape::Inline));
861            emit_u64_field(sink, "shown", shown as u64);
862            emit_u64_field(sink, "total", total as u64);
863            sink.event(Event::EndMap);
864        }
865    });
866}
867
868fn emit_file_row(sink: &mut impl Sink, row: &FileRow) {
869    sink.event(Event::BeginMap(Shape::Block));
870    emit_path_fields(sink, &row.path);
871    emit_str_field(sink, "kind", kind_label(row.kind));
872    emit_u64_field(sink, "bytes", row.bytes);
873    emit_u64_field(sink, "allocated", row.allocated);
874    emit_i64_field(sink, "mtime_ns", row.mtime_ns);
875    for (name, value) in [("files", row.files), ("dirs", row.dirs)] {
876        emit_field(sink, Field::nullable(name), true, |sink| match value {
877            Some(value) => emit_scalar(sink, Scalar::U64(value)),
878            None => emit_scalar(sink, Scalar::Null),
879        });
880    }
881    emit_field(sink, Field::nullable("complete"), true, |sink| match row.complete {
882        Some(value) => emit_scalar(sink, Scalar::Bool(value)),
883        None => emit_scalar(sink, Scalar::Null),
884    });
885    emit_field(sink, Field::nullable("age_ns"), true, |sink| match row.age_ns {
886        Some(value) => emit_scalar(sink, Scalar::I128(value)),
887        None => emit_scalar(sink, Scalar::Null),
888    });
889
890    emit_field(sink, Field::nullable("ignored"), true, |sink| match row.ignored {
891        Some(value) => emit_scalar(sink, Scalar::Bool(value)),
892        None => emit_scalar(sink, Scalar::Null),
893    });
894    emit_field(sink, Field::nullable("sort_value"), true, |sink| match row.sort_value {
895        Some(value) => emit_scalar(sink, Scalar::U64(value)),
896        None => emit_scalar(sink, Scalar::Null),
897    });
898    emit_field(sink, Field::nullable("classification"), true, |sink| match &row.classification {
899        Some(classification) => {
900            sink.event(Event::BeginMap(Shape::Block));
901            emit_str_field(sink, "file_type", classification.file_type.as_str());
902            emit_str_field(sink, "family", classification.family.as_str());
903            emit_str_field(sink, "source", classification.source.as_str());
904            emit_str_field(sink, "confidence", classification.confidence.as_str());
905            emit_field(sink, Field::always("flags"), true, |sink| {
906                sink.event(Event::BeginMap(Shape::Inline));
907                emit_bool_field(sink, "generated", classification.flags.generated);
908                emit_bool_field(sink, "vendored", classification.flags.vendored);
909                emit_bool_field(sink, "documentation", classification.flags.documentation);
910                sink.event(Event::EndMap);
911            });
912            sink.event(Event::EndMap);
913        }
914        None => emit_scalar(sink, Scalar::Null),
915    });
916    sink.event(Event::EndMap);
917}
918
919fn emit_summary_row(sink: &mut impl Sink, row: &SummaryRow) {
920    sink.event(Event::BeginMap(Shape::Block));
921    emit_u64_field(sink, "files", row.files);
922    emit_u64_field(sink, "dirs", row.dirs);
923    emit_u64_field(sink, "bytes", row.bytes);
924    emit_u64_field(sink, "allocated", row.allocated);
925    emit_field(sink, Field::nullable("ignored"), true, |sink| {
926        emit_ignored(sink, row.ignored, true);
927    });
928    emit_field(sink, Field::nullable("newest_mtime_ns"), true, |sink| match row.newest_mtime_ns {
929        Some(value) => emit_scalar(sink, Scalar::I64(value)),
930        None => emit_scalar(sink, Scalar::Null),
931    });
932    sink.event(Event::EndMap);
933}
934
935fn emit_ignored(sink: &mut impl Sink, ignored: Option<IgnoredTally>, with_dirs: bool) {
936    let Some(ignored) = ignored else {
937        emit_scalar(sink, Scalar::Null);
938        return;
939    };
940    sink.event(Event::BeginMap(Shape::Inline));
941    emit_u64_field(sink, "files", ignored.files);
942    if with_dirs {
943        emit_u64_field(sink, "dirs", ignored.dirs);
944    }
945    emit_u64_field(sink, "bytes", ignored.bytes);
946    emit_u64_field(sink, "allocated", ignored.allocated);
947    sink.event(Event::EndMap);
948}
949
950fn emit_metric_summary(sink: &mut impl Sink, summary: &MetricSummary) {
951    sink.event(Event::BeginMap(Shape::Block));
952    emit_str_field(sink, "group", metric_group_label(summary.group));
953    emit_str_field(sink, "share_metric", summary.share_metric.as_str());
954    emit_bound_field(sink, summary.rows.len(), summary.total_rows);
955    emit_u64_field(sink, "share_omitted", summary.share_omitted as u64);
956    emit_field(sink, Field::always("total"), true, |sink| {
957        emit_metric_row(sink, &summary.total, summary.words_per_page);
958    });
959    emit_field(sink, Field::always("rows"), true, |sink| {
960        sink.event(Event::BeginSeq(Shape::Block));
961        for row in &summary.rows {
962            emit_metric_row(sink, row, summary.words_per_page);
963        }
964        sink.event(Event::EndSeq);
965    });
966    sink.event(Event::EndMap);
967}
968
969fn emit_code_tally(sink: &mut impl Sink, tally: &CodeTally) {
970    sink.event(Event::BeginMap(Shape::Block));
971    emit_u64_field(sink, "source_files", tally.source_files);
972    emit_u64_field(sink, "analyzed_files", tally.analyzed_files);
973    emit_u64_field(sink, "code_lines", tally.metrics.code_lines);
974    emit_u64_field(sink, "comment_lines", tally.metrics.comment_lines);
975    emit_u64_field(sink, "blank_lines", tally.metrics.code_blank_lines);
976    emit_u64_field(sink, "missing_records", tally.missing_records);
977    emit_field(sink, Field::always("coverage"), true, |sink| {
978        emit_coverage_map(sink, &tally.coverage);
979    });
980    sink.event(Event::EndMap);
981}
982
983fn emit_optional_code_tally(sink: &mut impl Sink, tally: Option<&CodeTally>) {
984    match tally {
985        Some(tally) => emit_code_tally(sink, tally),
986        None => emit_scalar(sink, Scalar::Null),
987    }
988}
989
990fn emit_code_overview(sink: &mut impl Sink, overview: &CodeOverview) {
991    sink.event(Event::BeginMap(Shape::Block));
992    emit_str_field(sink, "population", overview.population.label());
993    emit_str_field(sink, "share_metric", overview.share_metric.as_str());
994    emit_bound_field(sink, overview.languages.len(), overview.total_languages);
995    emit_u64_field(sink, "share_omitted", overview.share_omitted as u64);
996    emit_u64_field(sink, "analyzed_languages", overview.analyzed_languages);
997    emit_u64_field(sink, "unclassified_files", overview.unclassified_files);
998    emit_field(sink, Field::always("selected"), true, |sink| {
999        emit_code_tally(sink, &overview.selected);
1000    });
1001    emit_field(sink, Field::nullable("non_ignored"), true, |sink| {
1002        emit_optional_code_tally(sink, overview.non_ignored.as_ref());
1003    });
1004    emit_field(sink, Field::nullable("ignored"), true, |sink| {
1005        emit_optional_code_tally(sink, overview.ignored.as_ref());
1006    });
1007    emit_field(sink, Field::always("unknown"), true, |sink| {
1008        emit_code_tally(sink, &overview.unknown);
1009    });
1010    emit_field(sink, Field::always("languages"), true, |sink| {
1011        sink.event(Event::BeginSeq(Shape::Block));
1012        for row in &overview.languages {
1013            sink.event(Event::BeginMap(Shape::Block));
1014            emit_str_field(sink, "language", &row.language);
1015            emit_field(sink, Field::always("share"), true, |sink| {
1016                sink.event(Event::BeginMap(Shape::Inline));
1017                emit_u64_field(sink, "numerator", row.share.numerator);
1018                emit_u64_field(sink, "denominator", row.share.denominator);
1019                sink.event(Event::EndMap);
1020            });
1021            emit_field(sink, Field::always("selected"), true, |sink| {
1022                emit_code_tally(sink, &row.selected);
1023            });
1024            emit_field(sink, Field::nullable("non_ignored"), true, |sink| {
1025                emit_optional_code_tally(sink, row.non_ignored.as_ref());
1026            });
1027            emit_field(sink, Field::nullable("ignored"), true, |sink| {
1028                emit_optional_code_tally(sink, row.ignored.as_ref());
1029            });
1030            emit_field(sink, Field::always("unknown"), true, |sink| {
1031                emit_code_tally(sink, &row.unknown);
1032            });
1033            sink.event(Event::EndMap);
1034        }
1035        sink.event(Event::EndSeq);
1036    });
1037    sink.event(Event::EndMap);
1038}
1039
1040fn emit_metric_row(sink: &mut impl Sink, row: &MetricRow, words_per_page: u64) {
1041    sink.event(Event::BeginMap(Shape::Block));
1042    emit_str_field(sink, "id", &row.id);
1043    emit_str_field(sink, "family", row.family.as_str());
1044    emit_u64_field(sink, "files", row.files);
1045    emit_u64_field(sink, "bytes", row.bytes);
1046    emit_u64_field(sink, "allocated", row.allocated);
1047    emit_field(sink, Field::always("share"), true, |sink| {
1048        sink.event(Event::BeginMap(Shape::Inline));
1049        emit_u64_field(sink, "numerator", row.share.numerator);
1050        emit_u64_field(sink, "denominator", row.share.denominator);
1051        sink.event(Event::EndMap);
1052    });
1053    emit_field(sink, Field::always("metrics"), true, |sink| {
1054        emit_metric_values(sink, row);
1055    });
1056    emit_field(sink, Field::always("coverage"), true, |sink| {
1057        sink.event(Event::BeginMap(Shape::Block));
1058        emit_analyzer_field(
1059            sink,
1060            row.analysis,
1061            Field::when_analyzer("lines", crate::content::AnalysisSet::LINES_ONLY),
1062            |sink| emit_coverage_map(sink, &row.lines_coverage),
1063        );
1064        emit_analyzer_field(
1065            sink,
1066            row.analysis,
1067            Field::when_analyzer("code", crate::content::AnalysisSet::CODE_ONLY),
1068            |sink| emit_coverage_map(sink, row.code_coverage.as_ref().expect("code requested")),
1069        );
1070        emit_analyzer_field(
1071            sink,
1072            row.analysis,
1073            Field::when_analyzer("words", crate::content::AnalysisSet::WORDS_ONLY),
1074            |sink| emit_coverage_map(sink, row.words_coverage.as_ref().expect("words requested")),
1075        );
1076        sink.event(Event::EndMap);
1077    });
1078    emit_field(sink, Field::always("detection"), true, |sink| {
1079        emit_detection(sink, row);
1080    });
1081    emit_analyzer_field(
1082        sink,
1083        row.analysis,
1084        Field::when_analyzer("pages", crate::content::AnalysisSet::WORDS_ONLY),
1085        |sink| {
1086            let page = pages(row, words_per_page).expect("words request has page inputs");
1087            sink.event(Event::BeginMap(Shape::Inline));
1088            emit_u64_field(sink, "words", page.words);
1089            emit_u64_field(sink, "words_per_page", page.words_per_page);
1090            sink.event(Event::EndMap);
1091        },
1092    );
1093    sink.event(Event::EndMap);
1094}
1095
1096fn emit_metric_values(sink: &mut impl Sink, row: &MetricRow) {
1097    sink.event(Event::BeginMap(Shape::Inline));
1098    for metric in METRICS {
1099        emit_analyzer_field(
1100            sink,
1101            row.analysis,
1102            Field::when_analyzer(metric.name, metric.owner),
1103            |sink| {
1104                emit_scalar(
1105                    sink,
1106                    Scalar::U64(row.metric_value(metric).expect("metric owner requested")),
1107                );
1108            },
1109        );
1110    }
1111    sink.event(Event::EndMap);
1112}
1113
1114fn emit_coverage_map(
1115    sink: &mut impl Sink,
1116    coverage: &std::collections::BTreeMap<CoverageReason, u64>,
1117) {
1118    sink.event(Event::BeginMap(Shape::Inline));
1119    for (reason, count) in coverage {
1120        emit_u64_field(sink, coverage_label(*reason), *count);
1121    }
1122    sink.event(Event::EndMap);
1123}
1124
1125fn emit_detection(sink: &mut impl Sink, row: &MetricRow) {
1126    sink.event(Event::BeginMap(Shape::Block));
1127    emit_field(sink, Field::always("sources"), true, |sink| {
1128        sink.event(Event::BeginMap(Shape::Inline));
1129        for (source, count) in &row.detection_sources {
1130            emit_u64_field(sink, source.as_str(), *count);
1131        }
1132        sink.event(Event::EndMap);
1133    });
1134    emit_field(sink, Field::always("confidence"), true, |sink| {
1135        sink.event(Event::BeginMap(Shape::Inline));
1136        for (level, count) in &row.detection_confidence {
1137            emit_u64_field(sink, level.as_str(), *count);
1138        }
1139        sink.event(Event::EndMap);
1140    });
1141    emit_field(sink, Field::always("flags"), true, |sink| {
1142        sink.event(Event::BeginMap(Shape::Inline));
1143        emit_u64_field(sink, "generated", row.generated_files);
1144        emit_u64_field(sink, "vendored", row.vendored_files);
1145        emit_u64_field(sink, "documentation", row.documentation_files);
1146        sink.event(Event::EndMap);
1147    });
1148    sink.event(Event::EndMap);
1149}
1150
1151fn emit_tree(sink: &mut impl Sink, root: &TreeNode) {
1152    enum Step<'a> {
1153        Node(&'a TreeNode),
1154        Children(std::slice::Iter<'a, TreeNode>),
1155        EndMap,
1156        EndSeq,
1157    }
1158    let mut stack = vec![Step::Node(root)];
1159    while let Some(step) = stack.pop() {
1160        match step {
1161            Step::Node(node) => {
1162                sink.event(Event::BeginMap(Shape::Block));
1163                emit_str_field(sink, "name", &node.name);
1164                emit_path_fields(sink, &node.path);
1165                emit_str_field(sink, "kind", kind_label(node.kind));
1166                emit_field(sink, Field::nullable("entry_ignored"), true, |sink| {
1167                    emit_scalar(sink, node.entry_ignored.map_or(Scalar::Null, Scalar::Bool));
1168                });
1169                emit_u64_field(sink, "bytes", node.bytes);
1170                emit_u64_field(sink, "allocated", node.allocated);
1171                emit_u64_field(sink, "files", node.files);
1172                emit_u64_field(sink, "dirs", node.dirs);
1173                emit_field(sink, Field::nullable("ignored"), true, |sink| {
1174                    emit_ignored(sink, node.ignored, true);
1175                });
1176                emit_field(sink, Field::nullable("newest_mtime_ns"), true, |sink| {
1177                    match node.newest_mtime_ns {
1178                        Some(value) => emit_scalar(sink, Scalar::I64(value)),
1179                        None => emit_scalar(sink, Scalar::Null),
1180                    }
1181                });
1182                emit_field(sink, Field::always("truncated"), true, |sink| {
1183                    emit_scalar(sink, Scalar::Bool(node.truncated));
1184                });
1185                emit_tree_omissions(sink, &node.omissions);
1186                sink.event(Event::Key("children"));
1187                sink.event(Event::BeginSeq(Shape::Block));
1188                stack.push(Step::EndMap);
1189                stack.push(Step::EndSeq);
1190                stack.push(Step::Children(node.children.iter()));
1191            }
1192            Step::Children(mut children) => {
1193                if let Some(child) = children.next() {
1194                    stack.push(Step::Children(children));
1195                    stack.push(Step::Node(child));
1196                }
1197            }
1198            Step::EndMap => sink.event(Event::EndMap),
1199            Step::EndSeq => sink.event(Event::EndSeq),
1200        }
1201    }
1202}
1203
1204fn emit_tree_omissions(sink: &mut impl Sink, omissions: &[crate::query::TreeOmission]) {
1205    sink.event(Event::Key("omissions"));
1206    sink.event(Event::BeginSeq(Shape::Block));
1207    for omission in omissions {
1208        sink.event(Event::BeginMap(Shape::Inline));
1209        emit_str_field(sink, "reason", omission.reason.label());
1210        emit_u64_field(sink, "entries", omission.entries as u64);
1211        sink.event(Event::Key("files"));
1212        emit_scalar(sink, omission.files.map_or(Scalar::Null, Scalar::U64));
1213        sink.event(Event::Key("bytes"));
1214        match omission.bytes {
1215            Some(value) => emit_scalar(sink, Scalar::U64(value)),
1216            None => emit_scalar(sink, Scalar::Null),
1217        }
1218        sink.event(Event::Key("allocated"));
1219        match omission.allocated {
1220            Some(value) => emit_scalar(sink, Scalar::U64(value)),
1221            None => emit_scalar(sink, Scalar::Null),
1222        }
1223        sink.event(Event::EndMap);
1224    }
1225    sink.event(Event::EndSeq);
1226}
1227
1228/// The report envelope's ordered field and presence contract.
1229struct ReportFields {
1230    schema: Field,
1231    generator: Field,
1232    root: Field,
1233    root_raw: Field,
1234    request: Field,
1235    status: Field,
1236    provenance: Field,
1237    ignore_rules: Field,
1238    analysis: Field,
1239    reports: Field,
1240    age_reference_ns: Field,
1241}
1242
1243const REPORT_FIELDS: ReportFields = ReportFields {
1244    schema: Field::always("schema"),
1245    generator: Field::always("generator"),
1246    root: Field::always("root"),
1247    root_raw: Field::when_lossy("root_raw"),
1248    request: Field::always("request"),
1249    status: Field::always("status"),
1250    provenance: Field::always("provenance"),
1251    ignore_rules: Field::always("ignore_rules"),
1252    analysis: Field::nullable("analysis"),
1253    reports: Field::when_set("reports"),
1254    age_reference_ns: Field::nullable("age_reference_ns"),
1255};
1256
1257/// Why a field is present in a wire document.
1258#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1259enum Presence {
1260    Always,
1261    Nullable,
1262    WhenLossy,
1263    WhenAnalyzer(crate::content::AnalysisSet),
1264    WhenSet,
1265}
1266
1267/// One declared field in a machine-output schema.
1268#[derive(Clone, Copy, Debug)]
1269struct Field {
1270    name: &'static str,
1271    presence: Presence,
1272}
1273
1274impl Field {
1275    const fn always(name: &'static str) -> Self {
1276        Self { name, presence: Presence::Always }
1277    }
1278
1279    const fn nullable(name: &'static str) -> Self {
1280        Self { name, presence: Presence::Nullable }
1281    }
1282
1283    const fn when_lossy(name: &'static str) -> Self {
1284        Self { name, presence: Presence::WhenLossy }
1285    }
1286
1287    const fn when_analyzer(name: &'static str, analysis: crate::content::AnalysisSet) -> Self {
1288        Self { name, presence: Presence::WhenAnalyzer(analysis) }
1289    }
1290
1291    const fn when_set(name: &'static str) -> Self {
1292        Self { name, presence: Presence::WhenSet }
1293    }
1294}
1295
1296/// Wrap text in a style when colour is on.
1297pub fn paint(text: &str, style: AnsiStyle, color: bool) -> String {
1298    if color { format!("{style}{text}{style:#}") } else { text.to_string() }
1299}
1300
1301/// Escape controls before styling so an entry cannot move a cursor or add a row.
1302pub fn escaped_human(text: &str) -> String {
1303    text.chars()
1304        .flat_map(|c| if c.is_control() { c.escape_default().collect::<Vec<_>>() } else { vec![c] })
1305        .collect()
1306}
1307
1308/// The shared human byte color role: zero is gray; large values are bold, including gray details.
1309/// The threshold uses exact bytes, not the rounded display unit.
1310pub fn byte_style(bytes: u64, secondary: bool) -> AnsiStyle {
1311    let style = if secondary || bytes == 0 { STYLE_DETAIL } else { AnsiStyle::new() };
1312    if bytes >= 1 << 30 { style.bold() } else { style }
1313}
1314
1315/// Render a human byte quantity after padding with the shared size and emphasis rules.
1316/// Plain and structured numbers never depend on styling.
1317pub fn styled_bytes(bytes: u64, width: usize, color: bool, secondary: bool) -> String {
1318    let style = byte_style(bytes, secondary);
1319    let text = format!("{:>width$}", human_bytes(bytes));
1320    if bytes > 0 && bytes < 1 << 30 && !secondary { text } else { paint(&text, style, color) }
1321}
1322
1323/// Style a root share using its exact ratio, before display rounding.
1324fn percentage_cell(part: u64, whole: u64, decimals: usize, width: usize, color: bool) -> String {
1325    let text = format!("{:>width$}", human_percentage(part, whole, decimals));
1326    if whole > 0 && u128::from(part) * 100 < u128::from(whole) {
1327        detail(&text, color)
1328    } else {
1329        text
1330    }
1331}
1332
1333/// Human directory markers are presentation only, never part of structured paths.
1334fn human_name(name: &str, kind: EntryKind, ignored: Option<bool>, color: bool) -> String {
1335    // Own classification matters: a directory merely containing ignored files keeps
1336    // the primary style, even if every selected descendant happens to be ignored.
1337    let style = if kind == EntryKind::Dir && ignored == Some(true) {
1338        STYLE_IGNORED_NAME
1339    } else {
1340        STYLE_NAME
1341    };
1342    let slash = kind == EntryKind::Dir && !matches!(name, "." | "..") && !name.ends_with('/');
1343    format!(
1344        "{}{}",
1345        paint(&escaped_human(name), style, color),
1346        if slash { detail("/", color) } else { String::new() }
1347    )
1348}
1349
1350fn detail(text: &str, color: bool) -> String {
1351    paint(text, STYLE_DETAIL, color)
1352}
1353
1354// ---- text ----
1355
1356/// Render the human-facing form.
1357///
1358/// A multi-view report introduces each section with an all-caps header naming its view,
1359/// blocks separated by a blank line. Machine formats already carry a `view` field on
1360/// every report, so text was the only format that lost the labelling: several tables of
1361/// similar-looking rows arrived concatenated, and the reader had to work out which view
1362/// each block came from by remembering the order they were requested in.
1363///
1364/// A single-view report is left bare, which keeps `fdu --view files` a listing of
1365/// paths and nothing else. Paths escape control characters for display; use a structured
1366/// format when arbitrary native filenames must be consumed without loss.
1367/// One block needs no label to be
1368/// unambiguous, so the header appears precisely when it disambiguates something.
1369fn render_text(report: &Report, options: RenderOptions) -> String {
1370    let color = options.color;
1371    let mut out = String::new();
1372    let headed = report.sections.len() > 1;
1373    for (index, section) in report.sections.iter().enumerate() {
1374        if index > 0 {
1375            out.push('\n');
1376        }
1377        // A single-view report has no header; its row bound is a display-limit note in
1378        // the epilogue rather than a line of its own above the result.
1379        if headed {
1380            let _ = writeln!(
1381                out,
1382                "{}{}",
1383                paint(view_header(section.view()), STYLE_HEADING, color),
1384                paint(&bound_note(section), STYLE_DETAIL, color),
1385            );
1386        }
1387        match section {
1388            Section::Code(overview) => render_text_code(&mut out, overview, color),
1389            Section::Tree { root, omissions, limits, .. } => {
1390                render_text_tree(
1391                    &mut out,
1392                    root.as_deref(),
1393                    omissions,
1394                    limits,
1395                    report.size,
1396                    report.ignored_entries,
1397                    options,
1398                );
1399            }
1400            Section::Extensions { rows, .. } => {
1401                render_text_types(&mut out, rows, report.size, report.ignored_entries, color);
1402            }
1403            Section::Metrics { view, summary } => {
1404                render_text_metrics(&mut out, *view, summary, report.size, color);
1405            }
1406            // `files` stays one path per line: it is the enumeration, and a bare list is
1407            // what pipes into xargs. The bounded presets are summaries, and a summary
1408            // that ranks by something must show that something — "the twenty largest"
1409            // with no sizes does not answer the question it is named for, and leaves the
1410            // ranking unverifiable.
1411            // The ranking measure is named by an epilogue note.
1412            Section::Files { rows, .. } if report.sort_metric.is_some() => {
1413                render_text_metric_files(&mut out, rows, color);
1414            }
1415            Section::Files { view, rows, .. } => match view {
1416                ViewSpec::Largest => {
1417                    render_text_ranked_files(&mut out, rows, color, Some(report.size), |row| {
1418                        human_bytes(pick(report.size, row.bytes, row.allocated))
1419                    });
1420                }
1421                ViewSpec::Recent => render_text_ranked_files(&mut out, rows, color, None, |row| {
1422                    format_rfc3339_nanos(row.mtime_ns)
1423                }),
1424                _ => {
1425                    for row in rows {
1426                        let _ = writeln!(out, "{}", escaped_human(&row.path.to_string_lossy()));
1427                    }
1428                }
1429            },
1430            Section::Summary(row) => {
1431                render_text_summary(&mut out, row, report.size, report.ignored_entries, color);
1432            }
1433        }
1434    }
1435    out
1436}
1437
1438// ---- the layout rules -----------------------------------------------------------------
1439//
1440// One contract every grouped row follows, so the four grouped views line up as one table
1441// rather than four:
1442//
1443//   size    right-aligned, width 10
1444//   share   right-aligned, width 6
1445//   label   left-aligned, padded to the section's widest label
1446//   files   the file count, after a single space, which ends the row's first line
1447//
1448// Every further measure a row carries is a continuation line of its own below it, in a
1449// fixed order: lines with their breakdown, words with pages, generated and vendored
1450// counts, then each coverage reason other than analyzed. A continuation leaves the size,
1451// share, and label cells blank and starts `TEXT_CONTINUATION_INDENT` columns right of the
1452// file count, so it reads as a breakdown of that tally, and every continuation in a
1453// section starts at one column. A row is then as wide as its widest measure rather than
1454// the sum of them: joined on one line, the Linux kernel's DOCUMENTS rows reached 154
1455// columns. A row with nothing past its file count stays one line.
1456//
1457// The documentation-file count is machine output only (`documentation_files`). It
1458// counts files under a doc, docs, or documentation directory or named like a README,
1459// CHANGELOG, or CONTRIBUTING file, which in DOCUMENTS is nearly every file, and a line
1460// under a document row would read as a measure of the documents themselves.
1461//
1462// The rule that is easy to get wrong: **a column's width is measured on visible text**.
1463// `paint` wraps its argument in escape sequences, and a width specifier counts those
1464// toward the field, so `{:<12}` on a painted label is already full before a single visible
1465// character lands and the padding silently collapses. `label_cell` is the only sanctioned
1466// way to lay out a styled label: it measures the plain text and appends the padding
1467// outside the paint. Never hand a painted string to `{:<N}` or `{:>N}`.
1468
1469/// The size column: right-aligned in a fixed width, never styled.
1470const TEXT_SIZE_WIDTH: usize = 10;
1471/// The share column: right-aligned in a fixed width, never styled.
1472const TEXT_SHARE_WIDTH: usize = 6;
1473/// How far a grouped row's continuation lines start right of its file count.
1474const TEXT_CONTINUATION_INDENT: usize = 2;
1475
1476/// A styled label padded to `width`, measured on the visible text.
1477fn label_cell(label: &str, width: usize, style: AnsiStyle, color: bool) -> String {
1478    let padding = " ".repeat(width.saturating_sub(display_width(label)));
1479    format!("{}{padding}", paint(label, style, color))
1480}
1481
1482/// The widest visible label in a set of rows, floored at `minimum`.
1483fn label_width<'a>(labels: impl Iterator<Item = &'a str>, minimum: usize) -> usize {
1484    minimum.max(labels.map(|label| display_width(&escaped_human(label))).max().unwrap_or_default())
1485}
1486
1487/// Terminal columns occupied by an already escaped, unstyled label.
1488///
1489/// Common zero-width combining marks and East Asian wide characters need different
1490/// treatment from Unicode scalar counts when columns are padded for a terminal.
1491fn display_width(text: &str) -> usize {
1492    text.chars()
1493        .map(|c| {
1494            let point = c as u32;
1495            if (0x0300..=0x036f).contains(&point)
1496                || (0x1ab0..=0x1aff).contains(&point)
1497                || (0x1dc0..=0x1dff).contains(&point)
1498                || (0x20d0..=0x20ff).contains(&point)
1499                || (0xfe20..=0xfe2f).contains(&point)
1500            {
1501                0
1502            } else if (0x1100..=0x115f).contains(&point)
1503                || (0x2e80..=0xa4cf).contains(&point)
1504                || (0xac00..=0xd7a3).contains(&point)
1505                || (0xf900..=0xfaff).contains(&point)
1506                || (0xfe10..=0xfe19).contains(&point)
1507                || (0xfe30..=0xfe6f).contains(&point)
1508                || (0xff01..=0xff60).contains(&point)
1509                || (0xffe0..=0xffe6).contains(&point)
1510                || (0x1f300..=0x1faff).contains(&point)
1511                || (0x20000..=0x3fffd).contains(&point)
1512            {
1513                2
1514            } else {
1515                1
1516            }
1517        })
1518        .sum()
1519}
1520
1521fn human_percentage(part: u64, whole: u64, decimals: usize) -> String {
1522    if whole == 0 {
1523        return "—".to_string();
1524    }
1525    let scale = match decimals {
1526        0 => 100_u128,
1527        1 => 1_000_u128,
1528        _ => panic!("human percentage supports whole and tenths only"),
1529    };
1530    // Test the less-than label exactly; float conversion can round a value just
1531    // below 1% (or 0.1%) onto the boundary when the byte totals exceed 2^53.
1532    if part > 0 && u128::from(part) * scale < u128::from(whole) {
1533        return if decimals == 0 {
1534            "<1%".to_string()
1535        } else {
1536            format!("<0.{}1%", "0".repeat(decimals - 1))
1537        };
1538    }
1539    format!("{:.*}%", decimals, ratio(part, whole) * 100.0)
1540}
1541
1542/// One grouped section: TYPES, FAMILIES, LANGUAGES, or DOCUMENTS.
1543///
1544/// Each row's first line is its size, share, label, and file count; every further
1545/// measure it carries is a continuation line of its own, as the layout rules above say.
1546fn render_text_metrics(
1547    out: &mut String,
1548    view: ViewSpec,
1549    summary: &MetricSummary,
1550    size: SizeMetric,
1551    color: bool,
1552) {
1553    // Languages pad one past the longest name; the other groupings share a floor so
1554    // separate sections still line up with one another.
1555    let width = if view == ViewSpec::Languages {
1556        label_width(summary.rows.iter().map(|row| human_metric_label(view, &row.id)), 0)
1557            .saturating_add(1)
1558    } else {
1559        label_width(
1560            summary.rows.iter().map(|row| human_metric_label(view, &row.id)),
1561            TEXT_METRIC_LABEL_WIDTH,
1562        )
1563    };
1564    // The file count starts one space past the size, share, and label cells, whose
1565    // visible widths are fixed for the section, so every continuation shares one column.
1566    let continuation = " "
1567        .repeat(TEXT_SIZE_WIDTH + 2 + TEXT_SHARE_WIDTH + 2 + width + 1 + TEXT_CONTINUATION_INDENT);
1568    for row in &summary.rows {
1569        let selected = pick(size, row.bytes, row.allocated);
1570        let percentage =
1571            percentage_cell(row.share.numerator, row.share.denominator, 1, TEXT_SHARE_WIDTH, color);
1572        let mut measures = Vec::new();
1573        if let Some(physical_lines) = row.metrics.physical_lines.filter(|lines| *lines > 0) {
1574            let code_fully_analyzed = row.code_coverage.as_ref().is_some_and(|coverage| {
1575                coverage.len() == 1 && coverage.get(&CoverageReason::Analyzed) == Some(&row.files)
1576            });
1577            let breakdown =
1578                if let (true, Some(code_lines), Some(comment_lines), Some(code_blank_lines)) = (
1579                    code_fully_analyzed,
1580                    row.metrics.code_lines,
1581                    row.metrics.comment_lines,
1582                    row.metrics.code_blank_lines,
1583                ) {
1584                    format!(
1585                        "({} code, {} comment, {} blank)",
1586                        human_count(code_lines),
1587                        human_count(comment_lines),
1588                        human_count(code_blank_lines)
1589                    )
1590                } else {
1591                    format!(
1592                        "({} nonblank, {} blank)",
1593                        human_count(row.metrics.nonblank_lines.expect("lines requested")),
1594                        human_count(row.metrics.blank_lines.expect("lines requested"))
1595                    )
1596                };
1597            measures.push(format!(
1598                "{} lines {}",
1599                human_count(physical_lines),
1600                detail(&breakdown, color)
1601            ));
1602        }
1603        if let Some(page) = pages(row, summary.words_per_page).filter(|page| page.words > 0) {
1604            let page_tenths = page.words.saturating_mul(10) / page.words_per_page;
1605            measures.push(format!(
1606                "{} words {}",
1607                human_count(page.words),
1608                detail(
1609                    &format!("({}.{:01} pages)", human_count(page_tenths / 10), page_tenths % 10),
1610                    color,
1611                )
1612            ));
1613        }
1614        // `documentation_files` stays out of human rows; see the layout rules above.
1615        for (count, flag) in [(row.generated_files, "generated"), (row.vendored_files, "vendored")]
1616        {
1617            if count > 0 {
1618                measures.push(format!("{} {flag}", human_count(count)));
1619            }
1620        }
1621        let coverage = match view {
1622            ViewSpec::Languages => row.code_coverage.as_ref().unwrap_or(&row.lines_coverage),
1623            ViewSpec::Documents => row.words_coverage.as_ref().unwrap_or(&row.lines_coverage),
1624            _ => &row.lines_coverage,
1625        };
1626        for (reason, count) in coverage {
1627            if *reason != CoverageReason::Analyzed {
1628                measures.push(format!("{} {}", human_count(*count), human_coverage_label(*reason)));
1629            }
1630        }
1631        let _ = writeln!(
1632            out,
1633            "{}  {}  {} {} {}",
1634            styled_bytes(selected, TEXT_SIZE_WIDTH, color, false),
1635            percentage,
1636            label_cell(
1637                &escaped_human(human_metric_label(view, &row.id)),
1638                width,
1639                STYLE_CATEGORY,
1640                color
1641            ),
1642            human_count(row.files),
1643            plural(row.files, "file", "files"),
1644        );
1645        for measure in measures {
1646            let _ = writeln!(out, "{continuation}{measure}");
1647        }
1648    }
1649}
1650
1651/// Keep measured zero distinct from missing measurements in every numeric column.
1652/// Language display bounds trim rows, never the TOTAL row or its global denominator.
1653fn render_text_code(out: &mut String, overview: &CodeOverview, color: bool) {
1654    let selected = &overview.selected;
1655    let code_width = human_count(selected.metrics.code_lines).len().max("Code lines".len());
1656    let comment_width = human_count(selected.metrics.comment_lines).len().max("Comments".len());
1657    let blank_width = human_count(selected.metrics.code_blank_lines).len().max("Blank".len());
1658    let analyzed_width =
1659        format!("{}/{}", human_count(selected.analyzed_files), human_count(selected.source_files))
1660            .len()
1661            .max("Analyzed files".len());
1662    let language_width = label_width(
1663        overview.languages.iter().map(|row| human_language_name(&row.language)),
1664        "Language".len(),
1665    );
1666    let _ = writeln!(
1667        out,
1668        "{}  {}  {}  {}  {}  {}",
1669        paint(&format!("{:>code_width$}", "Code lines"), STYLE_CATEGORY, color),
1670        paint(&format!("{:>6}", "Share"), STYLE_CATEGORY, color),
1671        paint(&format!("{:>comment_width$}", "Comments"), STYLE_CATEGORY, color),
1672        paint(&format!("{:>blank_width$}", "Blank"), STYLE_CATEGORY, color),
1673        paint(&format!("{:>analyzed_width$}", "Analyzed files"), STYLE_CATEGORY, color),
1674        paint("Language", STYLE_CATEGORY, color),
1675    );
1676    for row in &overview.languages {
1677        let measured = row.selected.analyzed_files > 0;
1678        // The row's value already includes every population, so the parenthetical names
1679        // only the gitignored share (and any unknown share), never its complement.
1680        let mut annotation = String::new();
1681        if let (true, Some(ignored)) = (measured, &row.ignored) {
1682            let _ = write!(annotation, "{} gitignored", human_count(ignored.metrics.code_lines));
1683        }
1684        if measured && row.unknown.source_files > 0 {
1685            let _ = write!(
1686                annotation,
1687                "{}{} unknown",
1688                if annotation.is_empty() { "" } else { ", " },
1689                human_count(row.unknown.metrics.code_lines)
1690            );
1691        }
1692        let annotation = if annotation.is_empty() {
1693            String::new()
1694        } else {
1695            format!(" {}", detail(&format!("({annotation})"), color))
1696        };
1697        let analyzed = format!(
1698            "{}/{}",
1699            human_count(row.selected.analyzed_files),
1700            human_count(row.selected.source_files)
1701        );
1702        let _ = writeln!(
1703            out,
1704            "{}  {}  {}  {}  {:>analyzed_width$}  {}{}",
1705            code_cell(row.selected.metrics.code_lines, measured, code_width, color),
1706            if measured {
1707                percentage_cell(row.share.numerator, row.share.denominator, 1, 6, color)
1708            } else {
1709                detail(&format!("{:>6}", "—"), color)
1710            },
1711            code_cell(row.selected.metrics.comment_lines, measured, comment_width, color),
1712            code_cell(row.selected.metrics.code_blank_lines, measured, blank_width, color),
1713            analyzed,
1714            label_cell(
1715                human_language_name(&row.language),
1716                if annotation.is_empty() { 0 } else { language_width },
1717                STYLE_NAME,
1718                color
1719            ),
1720            annotation
1721        );
1722    }
1723    let mut total_detail = String::new();
1724    if let (true, Some(ignored)) = (selected.analyzed_files > 0, &overview.ignored) {
1725        let _ = write!(total_detail, "{} gitignored", human_count(ignored.metrics.code_lines));
1726    }
1727    if selected.analyzed_files > 0 && overview.unknown.source_files > 0 {
1728        let _ = write!(
1729            total_detail,
1730            "{}{} unknown",
1731            if total_detail.is_empty() { "" } else { ", " },
1732            human_count(overview.unknown.metrics.code_lines)
1733        );
1734    }
1735    let total_detail = if total_detail.is_empty() {
1736        String::new()
1737    } else {
1738        format!(" {}", detail(&format!("({total_detail})"), color))
1739    };
1740    let bold = AnsiStyle::new().bold();
1741    let total_share = if selected.analyzed_files > 0 {
1742        percentage_cell(selected.metrics.code_lines, selected.metrics.code_lines, 1, 6, false)
1743    } else {
1744        format!("{:>6}", "—")
1745    };
1746    let total_analyzed = format!(
1747        "{:>analyzed_width$}",
1748        format!("{}/{}", human_count(selected.analyzed_files), human_count(selected.source_files))
1749    );
1750    let _ = writeln!(
1751        out,
1752        "{}  {}  {}  {}  {}  {}{}",
1753        code_total_cell(
1754            selected.metrics.code_lines,
1755            selected.analyzed_files > 0,
1756            code_width,
1757            color
1758        ),
1759        paint(&total_share, bold, color),
1760        code_total_cell(
1761            selected.metrics.comment_lines,
1762            selected.analyzed_files > 0,
1763            comment_width,
1764            color
1765        ),
1766        code_total_cell(
1767            selected.metrics.code_blank_lines,
1768            selected.analyzed_files > 0,
1769            blank_width,
1770            color
1771        ),
1772        paint(&total_analyzed, bold, color),
1773        paint(
1774            &format!(
1775                "{:<width$}",
1776                "TOTAL",
1777                width = if total_detail.is_empty() { 0 } else { language_width }
1778            ),
1779            bold,
1780            color
1781        ),
1782        total_detail
1783    );
1784    // Coverage facts (analyzed languages, unclassified and unanalyzed files) are notes in
1785    // the epilogue, not lines below the table.
1786}
1787
1788fn code_cell(value: u64, measured: bool, width: usize, color: bool) -> String {
1789    if measured {
1790        format!("{:>width$}", human_count(value))
1791    } else {
1792        detail(&format!("{:>width$}", "—"), color)
1793    }
1794}
1795
1796fn code_total_cell(value: u64, measured: bool, width: usize, color: bool) -> String {
1797    let text = if measured { human_count(value) } else { "—".to_owned() };
1798    paint(&format!("{text:>width$}"), AnsiStyle::new().bold(), color)
1799}
1800
1801/// The denominator of a percentage column that is not the byte column beside it.
1802///
1803/// Byte shares need no annotation because the adjacent size column already names their
1804/// numerator. Code and document reports deliberately rank by a content metric while
1805/// retaining bytes in the first column, so leaving the percentage unexplained makes two
1806/// unlike quantities look as though they must agree; the epilogue names it in a note.
1807pub(super) fn share_metric_label(metric: ShareMetric) -> Option<&'static str> {
1808    match metric {
1809        ShareMetric::CodeLines => Some("code lines"),
1810        ShareMetric::DocumentWords => Some("document words"),
1811        ShareMetric::RawWords => Some("raw words"),
1812        ShareMetric::ApparentBytes | ShareMetric::AllocatedBytes => None,
1813    }
1814}
1815
1816fn human_metric_label(view: ViewSpec, id: &str) -> &str {
1817    if view == ViewSpec::Languages { human_language_name(id) } else { id }
1818}
1819
1820pub(super) fn human_coverage_label(reason: CoverageReason) -> &'static str {
1821    match reason {
1822        CoverageReason::Analyzed => "analyzed",
1823        CoverageReason::Binary => "binary",
1824        CoverageReason::InvalidUtf8 => "invalid UTF-8",
1825        CoverageReason::UnsupportedEncoding => "unsupported encoding",
1826        CoverageReason::Unsupported => "unsupported",
1827        CoverageReason::IoError => "I/O error",
1828        CoverageReason::ChangedDuringRead => "changed during read",
1829        CoverageReason::TextOnly => "counted as text",
1830    }
1831}
1832
1833/// The gitignored subset a text row ends with, as ` (128 B gitignored)`, or nothing.
1834/// This amount is already included in the row total, not additional usage.
1835///
1836/// One placement for every row that carries a share: after the row's own detail, so the
1837/// fixed size, bar, and percentage columns keep their alignment. Nothing is appended when
1838/// no file is ignored, when the index observed no control state, or when the selection
1839/// admitted only ignored entries, where the share would repeat the row's size. A share of
1840/// ignored directories alone holds no bytes, and `(0 B gitignored)` would say nothing a
1841/// reader can act on; the machine formats still count them. Text cannot tell "nothing
1842/// ignored" from "no rules read"; the performance line says whether any rule was read,
1843/// and machine formats carry a zero share and `null` respectively.
1844fn ignored_suffix(
1845    ignored: Option<IgnoredTally>,
1846    size: SizeMetric,
1847    selected: IgnoredEntries,
1848    color: bool,
1849) -> String {
1850    let shown = match selected {
1851        IgnoredEntries::Include | IgnoredEntries::Exclude => {
1852            ignored.filter(|share| share.files > 0)
1853        }
1854        IgnoredEntries::Only => None,
1855    };
1856    shown.map_or_else(String::new, |share| {
1857        let bytes = pick(size, share.bytes, share.allocated);
1858        if bytes < 1 << 30 || !color {
1859            format!(" {}", detail(&format!("({} gitignored)", human_bytes(bytes)), color))
1860        } else {
1861            format!(
1862                " {}{}{}",
1863                detail("(", color),
1864                styled_bytes(bytes, 0, color, true),
1865                detail(" gitignored)", color)
1866            )
1867        }
1868    })
1869}
1870
1871/// Render a tree section with fixed bar, percentage, and size columns.
1872///
1873/// Iterative for the same reason the expansion is: a deep tree must render, not panic.
1874fn render_text_tree(
1875    out: &mut String,
1876    root: Option<&TreeNode>,
1877    omissions: &[crate::query::TreeOmission],
1878    _limits: &crate::query::TreeDisplayLimits,
1879    size: SizeMetric,
1880    selected: IgnoredEntries,
1881    options: RenderOptions,
1882) {
1883    let RenderOptions { color, bar_size } = options;
1884    let hidden = crate::query::TreeRemainder::from_tree(root, omissions);
1885    if let Some(root) = root {
1886        let grand = pick(size, root.bytes, root.allocated);
1887        let mut stack = vec![(root, 0)];
1888        while let Some((node, depth)) = stack.pop() {
1889            let bytes = pick(size, node.bytes, node.allocated);
1890            let indent = "  ".repeat(depth);
1891            let count = if node.kind == EntryKind::File {
1892                String::new()
1893            } else {
1894                format!(" {} {}", human_count(node.files), plural(node.files, "file", "files"))
1895            };
1896            let bar_prefix = if bar_size == 0 {
1897                String::new()
1898            } else {
1899                format!(
1900                    "{}  ",
1901                    usage_bar(
1902                        bytes,
1903                        grand,
1904                        node.ignored.map(|value| pick(size, value.bytes, value.allocated)),
1905                        color,
1906                        bar_size,
1907                    )
1908                )
1909            };
1910            let _ = writeln!(
1911                out,
1912                "{bar_prefix}{}  {}  {indent}{}{}{}",
1913                percentage_cell(bytes, grand, 0, 5, color),
1914                styled_bytes(bytes, 10, color, false),
1915                human_name(&node.name, node.kind, node.entry_ignored, color),
1916                count,
1917                ignored_suffix(node.ignored, size, selected, color),
1918            );
1919            stack.extend(node.children.iter().rev().map(|child| (child, depth + 1)));
1920        }
1921    }
1922    // One annotation at the highest displayed level, even when several independent
1923    // bounds hide descendants at different depths. Reasons belong in the epilogue.
1924    if let Some(hidden) = hidden {
1925        let grand = root.map(|node| pick(size, node.bytes, node.allocated));
1926        render_tree_remainder(out, &hidden, grand, usize::from(root.is_some()), size, options);
1927    }
1928}
1929
1930/// Human projection of the same remainder serialized in machine formats.
1931fn render_tree_remainder(
1932    out: &mut String,
1933    remainder: &crate::query::TreeRemainder,
1934    grand: Option<u64>,
1935    depth: usize,
1936    size: SizeMetric,
1937    options: RenderOptions,
1938) {
1939    let RenderOptions { color, bar_size } = options;
1940    let bytes = match size {
1941        SizeMetric::Apparent => remainder.bytes,
1942        SizeMetric::Allocated => remainder.allocated,
1943    };
1944    let measure = bytes.map_or_else(
1945        || detail(&format!("{:>10}", "unknown"), color),
1946        |bytes| styled_bytes(bytes, 10, color, false),
1947    );
1948    // With no visible root, a known remainder represents the whole selected root.
1949    // A missing measurement cannot honestly produce either a bar or a percentage.
1950    let (usage_bar, percentage) = match bytes.zip(grand.or(bytes)) {
1951        Some((bytes, grand)) => (
1952            usage_bar(
1953                bytes,
1954                grand,
1955                remainder.ignored.map(|value| pick(size, value.bytes, value.allocated)),
1956                color,
1957                bar_size,
1958            ),
1959            percentage_cell(bytes, grand, 0, 5, color),
1960        ),
1961        None => (" ".repeat(bar_size), detail(&format!("{:>5}", "—"), color)),
1962    };
1963    let files = remainder.files.map_or_else(
1964        || format!("more files {}", detail("(count unknown)", color)),
1965        |files| format!("{} more {}", human_count(files), plural(files, "file", "files")),
1966    );
1967    let indent = "  ".repeat(depth);
1968    let note = format!("{} {files}", detail(&format!("{indent}… and"), color));
1969    let bar_prefix = if bar_size == 0 { String::new() } else { format!("{usage_bar}  ") };
1970    let _ = writeln!(out, "{bar_prefix}{percentage}  {measure}  {note}");
1971}
1972
1973/// Render a types section as aligned rows.
1974fn render_text_types(
1975    out: &mut String,
1976    rows: &[TypeRow],
1977    size: SizeMetric,
1978    selected: IgnoredEntries,
1979    color: bool,
1980) {
1981    let width = label_width(rows.iter().map(|row| row.extension.as_str()), TEXT_TYPE_LABEL_WIDTH);
1982    for row in rows {
1983        let _ = writeln!(
1984            out,
1985            "{}  {} {} {}{}",
1986            styled_bytes(pick(size, row.bytes, row.allocated), TEXT_SIZE_WIDTH, color, false),
1987            label_cell(&escaped_human(&row.extension), width, STYLE_CATEGORY, color),
1988            human_count(row.files),
1989            plural(row.files, "file", "files"),
1990            ignored_suffix(row.ignored, size, selected, color),
1991        );
1992    }
1993}
1994
1995/// Counts row omissions after share filtering, so a share threshold alone never
1996/// produces a row-limit remedy.
1997fn bounded_rows(section: &Section) -> Option<(usize, usize)> {
1998    let (shown, total) = match section {
1999        Section::Code(overview) => (overview.languages.len(), overview.total_languages),
2000        Section::Files { rows, total, .. } => (rows.len(), *total),
2001        Section::Extensions { rows, total, .. } => (rows.len(), *total),
2002        Section::Metrics { summary, .. } => (summary.rows.len(), summary.total_rows),
2003        // Trees carry their own omission records; summary cannot be bounded.
2004        Section::Tree { .. } | Section::Summary(_) => return None,
2005    };
2006    (shown < total).then_some((shown, total))
2007}
2008
2009/// Factual row count near a bounded section. Actionable guidance is emitted once at
2010/// the end of the report by the shared diagnostic collector.
2011fn bound_note(section: &Section) -> String {
2012    let Some((shown, total)) = bounded_rows(section) else {
2013        return String::new();
2014    };
2015    format!("  ({} of {})", human_count(shown as u64), human_count(total as u64))
2016}
2017
2018/// A bounded flat listing, showing the measure it was ranked by.
2019///
2020/// The measure comes first at a fixed width so the paths line up under it, matching the
2021/// grouped views' size-then-label shape rather than inventing a third layout.
2022fn render_text_ranked_files(
2023    out: &mut String,
2024    rows: &[FileRow],
2025    color: bool,
2026    size: Option<SizeMetric>,
2027    measure: impl Fn(&FileRow) -> String,
2028) {
2029    let width = rows.iter().map(|row| display_width(&measure(row))).max().unwrap_or_default();
2030    for row in rows {
2031        let value = size.map_or_else(
2032            || format!("{:>width$}", measure(row)),
2033            |size| styled_bytes(pick(size, row.bytes, row.allocated), width, color, false),
2034        );
2035        let _ = writeln!(
2036            out,
2037            "{}  {}",
2038            value,
2039            human_name(&row.path.to_string_lossy(), row.kind, row.ignored, color)
2040        );
2041    }
2042}
2043
2044fn render_text_metric_files(out: &mut String, rows: &[FileRow], color: bool) {
2045    let width = rows
2046        .iter()
2047        .map(|row| row.sort_value.map_or(1, |value| human_count(value).len()))
2048        .max()
2049        .unwrap_or_default();
2050    for row in rows {
2051        let value = row.sort_value.map_or_else(|| "—".to_string(), human_count);
2052        let classification =
2053            row.classification.as_ref().map_or_else(String::new, |classification| {
2054                let mut parts =
2055                    vec![classification.file_type.as_str(), classification.source.as_str()];
2056                if classification.flags.generated {
2057                    parts.push("generated");
2058                }
2059                if classification.flags.vendored {
2060                    parts.push("vendored");
2061                }
2062                if classification.flags.documentation {
2063                    parts.push("documentation");
2064                }
2065                format!(" {}", detail(&format!("({})", parts.join(", ")), color))
2066            });
2067        let _ = writeln!(
2068            out,
2069            "{:>width$}  {}{}",
2070            value,
2071            human_name(&row.path.to_string_lossy(), row.kind, row.ignored, color),
2072            classification
2073        );
2074    }
2075}
2076
2077fn render_text_summary(
2078    out: &mut String,
2079    row: &SummaryRow,
2080    size: SizeMetric,
2081    selected: IgnoredEntries,
2082    color: bool,
2083) {
2084    let _ = writeln!(
2085        out,
2086        "{}  {} {}, {} {}{}",
2087        styled_bytes(pick(size, row.bytes, row.allocated), 10, color, false),
2088        human_count(row.files),
2089        plural(row.files, "file", "files"),
2090        human_count(row.dirs),
2091        plural(row.dirs, "directory", "directories"),
2092        ignored_suffix(row.ignored, size, selected, color),
2093    );
2094}
2095
2096/// Quote a YAML scalar whenever a bare word would be ambiguous.
2097///
2098/// Always quoting would be simpler and uglier; quoting only what needs it keeps the
2099/// output readable, which is the reason to offer YAML at all.
2100#[cfg(test)]
2101fn yaml_scalar(value: &str) -> String {
2102    let mut out = String::new();
2103    crate::emit::write_yaml_scalar(&mut out, value);
2104    out
2105}
2106
2107/// Quote and escape a string as a JSON scalar.
2108#[cfg(test)]
2109fn quote(text: &str) -> String {
2110    let mut out = String::with_capacity(text.len() + 2);
2111    crate::emit::write_json_string(&mut out, text);
2112    out
2113}
2114
2115/// This binary's identity, for the `generator` field.
2116fn generator() -> String {
2117    format!("fdu {}", env!("CARGO_PKG_VERSION"))
2118}
2119
2120/// All-caps header naming a view in multi-view text output.
2121///
2122/// Deliberately not `view.label().to_uppercase()`: the wire label is a schema promise
2123/// machine consumers match on, and deriving the human header from it would let a
2124/// presentation change reach into the schema, or freeze the schema for a presentation
2125/// reason. They spell the same word today because the same word is right in both places,
2126/// and a test holds them in step rather than a shared expression.
2127pub(super) fn view_header(view: ViewSpec) -> &'static str {
2128    match view {
2129        ViewSpec::List => "LIST",
2130        ViewSpec::Tree => "TREE",
2131        ViewSpec::Types => "TYPES",
2132        ViewSpec::Extensions => "EXTENSIONS",
2133        ViewSpec::Families => "FAMILIES",
2134        ViewSpec::Languages => "LANGUAGES",
2135        ViewSpec::Code => "CODE",
2136        ViewSpec::Documents => "DOCUMENTS",
2137        ViewSpec::Files => "FILES",
2138        ViewSpec::Largest => "LARGEST",
2139        ViewSpec::Recent => "RECENT",
2140        ViewSpec::Summary => "SUMMARY",
2141    }
2142}
2143
2144fn metric_group_label(group: MetricGroup) -> &'static str {
2145    match group {
2146        MetricGroup::Type => "type",
2147        MetricGroup::Family => "family",
2148    }
2149}
2150
2151fn coverage_label(reason: CoverageReason) -> &'static str {
2152    match reason {
2153        CoverageReason::Analyzed => "analyzed",
2154        CoverageReason::Binary => "binary",
2155        CoverageReason::InvalidUtf8 => "invalid_utf8",
2156        CoverageReason::UnsupportedEncoding => "unsupported_encoding",
2157        CoverageReason::Unsupported => "unsupported",
2158        CoverageReason::IoError => "io_error",
2159        CoverageReason::ChangedDuringRead => "changed_during_read",
2160        CoverageReason::TextOnly => "text_only",
2161    }
2162}
2163
2164/// The requested analyzer set, in the vocabulary `--analyze` accepts.
2165///
2166/// A list rather than one label because the set is what was requested; the neighbouring
2167/// `analyzers` array reports what actually ran, with each dialect's version.
2168fn analysis_set_labels(profile: crate::content::AnalysisSet) -> Vec<&'static str> {
2169    profile.labels()
2170}
2171
2172/// Stable wire label for a cache tier.
2173fn source_label(source: ReportSource) -> &'static str {
2174    match source {
2175        ReportSource::ColdScan => "cold_scan",
2176        ReportSource::WarmRevalidate => "warm_revalidate",
2177        ReportSource::CacheOnly => "cache_only",
2178    }
2179}
2180
2181fn tier_source_label(source: Source) -> &'static str {
2182    match source {
2183        Source::Scanned => "scanned",
2184        Source::Revalidated => "revalidated",
2185        Source::JournalScoped => "journal_scoped",
2186        Source::Cached => "cached",
2187    }
2188}
2189
2190fn structural_coverage_label(reason: crate::engine_contract::CoverageReason) -> &'static str {
2191    match reason {
2192        crate::engine_contract::CoverageReason::Building => "building",
2193        crate::engine_contract::CoverageReason::Budget => "budget",
2194        crate::engine_contract::CoverageReason::Cancelled => "cancelled",
2195        crate::engine_contract::CoverageReason::Inaccessible => "inaccessible",
2196        crate::engine_contract::CoverageReason::Failed => "failed",
2197    }
2198}
2199
2200fn issue_kind_label(kind: IssueKind) -> &'static str {
2201    match kind {
2202        IssueKind::Permission => "permission",
2203        IssueKind::Disappeared => "disappeared",
2204        IssueKind::InvalidMetadata => "invalid_metadata",
2205        IssueKind::ResourceBudget => "resource_budget",
2206        IssueKind::ObservationGap => "observation_gap",
2207        IssueKind::ProviderFailure => "provider_failure",
2208    }
2209}
2210
2211/// Stable wire label for freshness.
2212fn freshness_label(freshness: Freshness) -> &'static str {
2213    match freshness {
2214        Freshness::Fresh => "fresh",
2215        Freshness::Reconciling => "reconciling",
2216        Freshness::Stale => "stale",
2217        Freshness::Partial => "partial",
2218    }
2219}
2220
2221/// Stable wire label for an entry kind.
2222fn kind_label(kind: EntryKind) -> &'static str {
2223    match kind {
2224        EntryKind::File => "file",
2225        EntryKind::Dir => "dir",
2226        EntryKind::Symlink => "symlink",
2227        EntryKind::Other => "other",
2228    }
2229}
2230
2231/// The byte count for the metric a report answers in.
2232fn pick(size: SizeMetric, apparent: u64, allocated: u64) -> u64 {
2233    match size {
2234        SizeMetric::Apparent => apparent,
2235        SizeMetric::Allocated => allocated,
2236    }
2237}
2238
2239/// Pick the singular or plural noun for a count.
2240fn plural<'a>(count: u64, singular: &'a str, plural: &'a str) -> &'a str {
2241    if count == 1 { singular } else { plural }
2242}
2243
2244/// A bounded share for the human size bar and percentage.
2245fn ratio(part: u64, whole: u64) -> f64 {
2246    if whole == 0 {
2247        return 0.0;
2248    }
2249    #[allow(clippy::cast_precision_loss)]
2250    let share = part as f64 / whole as f64;
2251    share.clamp(0.0, 1.0)
2252}
2253
2254// Rounding a bounded share to a requested bar width is presentation arithmetic:
2255// clamp the ratio and the resulting cell count before constructing the glyphs.
2256/// Nearest whole-cell share, with half cells rounded up and no floating-point loss.
2257fn bar_cells(part: u64, whole: u64, width: usize) -> usize {
2258    if whole == 0 {
2259        return 0;
2260    }
2261    let numerator = u128::from(part.min(whole)) * width as u128;
2262    usize::try_from((numerator + u128::from(whole) / 2) / u128::from(whole))
2263        .expect("a bounded share fits the supplied width")
2264}
2265
2266/// Split a colored bar into measured populations; plain bars retain their glyphs.
2267/// Round the total against the root, then apportion its visible cells by the row's
2268/// population ratio. Independent root-relative rounding can erase a majority ignored
2269/// population in a one-cell bar. Shading distinguishes populations within green;
2270/// unknown classification uses medium shading. Numeric columns remain authoritative.
2271fn usage_bar(bytes: u64, total: u64, ignored: Option<u64>, color: bool, width: usize) -> String {
2272    if width == 0 {
2273        return String::new();
2274    }
2275    let filled = bar_cells(bytes, total, width);
2276    if !color {
2277        return format!("{}{}", "█".repeat(filled), "░".repeat(width - filled));
2278    }
2279    if ignored.is_none() {
2280        return format!(
2281            "{}{}",
2282            paint(&"▒".repeat(filled), STYLE_BAR, true),
2283            paint(&"░".repeat(width - filled), STYLE_BAR.dimmed(), true)
2284        );
2285    }
2286    let ignored = bar_cells(ignored.unwrap_or(0), bytes, filled);
2287    format!(
2288        "{}{}{}",
2289        paint(&"█".repeat(filled - ignored), STYLE_BAR, true),
2290        paint(&"▓".repeat(ignored), STYLE_BAR, true),
2291        paint(&"░".repeat(width - filled), STYLE_BAR.dimmed(), true)
2292    )
2293}
2294
2295/// Render a count with thousands separators, the way every fdu report does.
2296///
2297/// Lived in the command line, and `report_format` called *into* it -- so the library
2298/// depended on its own front end, which is the inverse of the rule that the CLI invents
2299/// nothing. A crate boundary rejects that outright, which is how it was found.
2300pub fn human_count(value: u64) -> String {
2301    human_count_u128(u128::from(value))
2302}
2303
2304/// Group a full-width count using the same human policy as [`human_count`].
2305///
2306/// Keep the grouping rule here so a future locale or no-grouping choice changes both
2307/// widths together without losing precision in rates wider than u64.
2308pub fn human_count_u128(value: u128) -> String {
2309    let digits = value.to_string();
2310    let mut grouped = String::with_capacity(digits.len() + digits.len() / 3);
2311    for (index, byte) in digits.bytes().enumerate() {
2312        if index > 0 && (digits.len() - index) % 3 == 0 {
2313            grouped.push(',');
2314        }
2315        grouped.push(char::from(byte));
2316    }
2317    grouped
2318}
2319
2320/// Render a byte count at human scale, the way every fdu report does.
2321///
2322/// Public because a caller formatting fdu's numbers should not reimplement its unit
2323/// rules: two spellings of one quantity inside a single tool is how a report and the
2324/// line summarising it come to disagree.
2325pub fn human_bytes(bytes: u64) -> String {
2326    const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
2327    // Integer arithmetic to the unit, then one bounded division for the tenths digit:
2328    // a byte count can exceed f64's exact-integer range, and a size that renders wrong
2329    // at the top of the scale is worse than one that renders plainly.
2330    let mut whole = bytes;
2331    let mut remainder = 0u64;
2332    let mut unit = 0;
2333    while whole >= 1024 && unit + 1 < UNITS.len() {
2334        remainder = whole % 1024;
2335        whole /= 1024;
2336        unit += 1;
2337    }
2338    if unit == 0 {
2339        format!("{} B", human_count(bytes))
2340    } else if whole < 10 {
2341        let tenths = (remainder * 10) / 1024;
2342        format!("{}.{tenths} {}", human_count(whole), UNITS[unit])
2343    } else {
2344        format!("{} {}", human_count(whole), UNITS[unit])
2345    }
2346}
2347
2348/// Whether a path renders losslessly as UTF-8.
2349///
2350/// Non-UTF-8 names exist and a report must not pretend otherwise; the CLI layer adds the
2351/// raw-bytes companion field, and this is the predicate that decides when.
2352pub fn is_lossy(path: &Path) -> bool {
2353    path.to_str().is_none()
2354}
2355
2356/// Streaming-output schema identity.
2357///
2358/// Distinct from the one-shot schema on purpose: a stream is a sequence of tagged
2359/// records over time, not one document, and a consumer should not have to discover which
2360/// it is holding.
2361pub const STREAM_SCHEMA: &str = "fdu.stream/2";
2362
2363/// The rule drawn above a watch repaint, carrying the instant it was rendered.
2364///
2365/// The time is what makes the rule worth a line rather than a bare separator: a watch
2366/// reader wants to know when the tree last moved, and it is the one fact that
2367/// distinguishes one repaint from another whose numbers happen to match. RFC 3339 in UTC
2368/// is the spelling every other timestamp this tool prints uses.
2369///
2370/// Lives here rather than in the command line because it is presentation, and a caller
2371/// repainting fdu's views should draw fdu's separator rather than invent one that will
2372/// drift from it.
2373pub fn watch_rule(at: std::time::SystemTime) -> String {
2374    format!("──── {} ────", format_rfc3339(at))
2375}
2376
2377/// The watch repaint rule for an integer nanosecond timestamp.
2378///
2379/// This is distinct from [`watch_rule`] because an integer can carry finer precision than
2380/// the platform's [`std::time::SystemTime`]. In particular, Windows would otherwise
2381/// truncate the final two digits while converting through 100-nanosecond FILETIME ticks.
2382pub fn watch_rule_nanos(at_nanos: i64) -> String {
2383    format!("──── {} ────", format_rfc3339_nanos(at_nanos))
2384}
2385
2386/// Render one streamed change as a tagged record.
2387#[cfg(feature = "watch")]
2388pub fn render_change(change: &crate::Change, format: Format) -> String {
2389    let kind = match change.kind {
2390        crate::ChangeKind::Upsert => "upsert",
2391        crate::ChangeKind::Remove => "remove",
2392        crate::ChangeKind::Invalidate => "invalidate",
2393    };
2394
2395    if !format.is_machine() {
2396        // Path first, so the stream stays greppable and cuts the same way a one-shot
2397        // listing does; the operation follows on the same line.
2398        return format!("{}\t{kind}", change.path.display());
2399    }
2400
2401    match format {
2402        Format::Json | Format::Jsonl => render_change_machine(change, kind, JsonSink::line()),
2403        Format::Yaml => {
2404            format!(
2405                "{}{}",
2406                document_start(format),
2407                render_change_machine(change, kind, YamlSink::new())
2408            )
2409        }
2410        Format::Text | Format::Tree | Format::Paths | Format::Long => {
2411            unreachable!("text returned above")
2412        }
2413    }
2414}
2415
2416#[cfg(feature = "watch")]
2417fn render_change_machine(
2418    change: &crate::Change,
2419    kind: &str,
2420    mut sink: impl Sink<Output = String>,
2421) -> String {
2422    sink.event(Event::BeginMap(Shape::Block));
2423    emit_str_field(&mut sink, "schema", STREAM_SCHEMA);
2424    emit_str_field(&mut sink, "record", "change");
2425    emit_str_field(&mut sink, "op", kind);
2426    emit_path_fields(&mut sink, &change.path);
2427    emit_u64_field(&mut sink, "clock", change.clock);
2428    emit_field(&mut sink, Field::when_set("kind"), change.entry_kind.is_some(), |sink| {
2429        emit_scalar(sink, Scalar::Str(kind_label(change.entry_kind.expect("presence checked"))));
2430    });
2431    emit_field(&mut sink, Field::when_set("bytes"), change.bytes.is_some(), |sink| {
2432        emit_scalar(sink, Scalar::U64(change.bytes.expect("presence checked")));
2433    });
2434    emit_field(&mut sink, Field::when_set("allocated"), change.allocated.is_some(), |sink| {
2435        emit_scalar(sink, Scalar::U64(change.allocated.expect("presence checked")));
2436    });
2437    emit_field(&mut sink, Field::when_set("mtime_ns"), change.mtime_ns.is_some(), |sink| {
2438        emit_scalar(sink, Scalar::I64(change.mtime_ns.expect("presence checked")));
2439    });
2440    emit_field(&mut sink, Field::when_set("ignored"), change.ignored.is_some(), |sink| {
2441        emit_scalar(sink, Scalar::Bool(change.ignored.expect("presence checked")));
2442    });
2443    sink.event(Event::EndMap);
2444    sink.finish()
2445}
2446
2447/// Lossless identity for a path that does not render as UTF-8.
2448///
2449/// `to_string_lossy` replaces undecodable bytes with U+FFFD, so a consumer reading only
2450/// `root` cannot tell two different names apart. Machine output therefore carries the
2451/// native bytes alongside, and only when they are actually needed.
2452fn raw_os_identity(value: &std::ffi::OsStr) -> Option<(&'static str, String)> {
2453    if value.to_str().is_some() {
2454        return None;
2455    }
2456
2457    #[cfg(unix)]
2458    {
2459        use std::os::unix::ffi::OsStrExt;
2460
2461        Some(("unix-bytes", hex_bytes(value.as_bytes().iter().copied())))
2462    }
2463
2464    #[cfg(windows)]
2465    {
2466        use std::os::windows::ffi::OsStrExt;
2467
2468        Some(("windows-wtf16le", hex_bytes(value.encode_wide().flat_map(u16::to_le_bytes))))
2469    }
2470
2471    #[cfg(not(any(unix, windows)))]
2472    {
2473        None
2474    }
2475}
2476
2477/// Hex-encode bytes for the raw identity field.
2478#[cfg(any(unix, windows))]
2479fn hex_bytes(bytes: impl IntoIterator<Item = u8>) -> String {
2480    let mut out = String::new();
2481    for byte in bytes {
2482        let _ = write!(out, "{byte:02x}");
2483    }
2484    out
2485}
2486
2487/// Render cache status in any format.
2488///
2489/// A separate entry point rather than a `Report` section: cache status is a fact about
2490/// the cache directory, not about a tree, and folding it into the report schema would
2491/// make every consumer parse a variant that is empty on every normal run.
2492///
2493/// `scope` is the request the statuses answer. It decides only which command the text
2494/// names for reclaiming stale snapshots: a root's snapshot is cleared by its path, while a
2495/// stale file found by listing the directory may name no root this build can read.
2496///
2497/// Every machine format carries [`CACHE_SCHEMA`], the way every machine report carries its
2498/// own: the first field of the JSON document, an envelope line of its own ahead of the
2499/// rows in JSON Lines, and the first line of the YAML.
2500pub fn render_cache_status(
2501    statuses: &[crate::CacheStatus],
2502    scope: crate::CacheScope,
2503    format: Format,
2504) -> String {
2505    render_cache_status_with_options(statuses, scope, format, RenderOptions::default())
2506}
2507
2508/// Render cache status with the same human color roles as report rows.
2509/// Machine formats ignore the presentation options; cache text has no usage bar.
2510pub fn render_cache_status_with_options(
2511    statuses: &[crate::CacheStatus],
2512    scope: crate::CacheScope,
2513    format: Format,
2514    options: RenderOptions,
2515) -> String {
2516    match format {
2517        Format::Jsonl => {
2518            let mut sink = JsonSink::line();
2519            sink.event(Event::BeginMap(Shape::Inline));
2520            emit_str_field(&mut sink, "schema", CACHE_SCHEMA);
2521            sink.event(Event::EndMap);
2522            let mut out = sink.finish();
2523            for status in statuses {
2524                out.push('\n');
2525                let row = cache_row(status);
2526                let mut sink = JsonSink::line();
2527                emit_cache_field(&mut sink, &row);
2528                out.push_str(&sink.finish());
2529            }
2530            out
2531        }
2532        Format::Yaml => render_cache_machine(statuses, YamlSink::new()),
2533        // The human layout lives here beside every other human layout. It used to live in
2534        // the CLI, which meant the only way to print cache status the way fdu prints it
2535        // was to be the CLI: the Python API returned CacheStatus values nothing could
2536        // render, so the parity shim printed repr() and nine sessions differed (fdu-1kw3).
2537        Format::Text | Format::Tree | Format::Paths | Format::Long => {
2538            render_cache_status_text(statuses, scope, options.color)
2539        }
2540        Format::Json => render_cache_machine(statuses, JsonSink::pretty()),
2541    }
2542}
2543
2544fn render_cache_machine(
2545    statuses: &[crate::CacheStatus],
2546    mut sink: impl Sink<Output = String>,
2547) -> String {
2548    sink.event(Event::BeginMap(Shape::Block));
2549    emit_str_field(&mut sink, "schema", CACHE_SCHEMA);
2550    emit_field(&mut sink, Field::always("caches"), true, |sink| {
2551        sink.event(Event::BeginSeq(Shape::Block));
2552        for status in statuses {
2553            let row = cache_row(status);
2554            emit_cache_field(sink, &row);
2555        }
2556        sink.event(Event::EndSeq);
2557    });
2558    sink.event(Event::EndMap);
2559    sink.finish()
2560}
2561
2562fn emit_cache_field(sink: &mut impl Sink, field: &CacheField) {
2563    match field {
2564        CacheField::Null => emit_scalar(sink, Scalar::Null),
2565        CacheField::Bool(value) => emit_scalar(sink, Scalar::Bool(*value)),
2566        CacheField::Count(value) => emit_scalar(sink, Scalar::U64(*value)),
2567        CacheField::Text(value) => emit_scalar(sink, Scalar::Str(value)),
2568        CacheField::List(values) => {
2569            sink.event(Event::BeginSeq(Shape::Block));
2570            for value in values {
2571                emit_cache_field(sink, value);
2572            }
2573            sink.event(Event::EndSeq);
2574        }
2575        CacheField::Map(fields) => {
2576            sink.event(Event::BeginMap(Shape::Block));
2577            for (name, value) in fields {
2578                sink.event(Event::Key(name));
2579                emit_cache_field(sink, value);
2580            }
2581            sink.event(Event::EndMap);
2582        }
2583    }
2584}
2585
2586/// One value in a cache-status row.
2587///
2588/// The row is built once as fields and serialized by JSON and YAML alike, so the two
2589/// formats cannot disagree about which keys a row has or how an identity nests.
2590enum CacheField {
2591    Null,
2592    Bool(bool),
2593    Count(u64),
2594    Text(String),
2595    List(Vec<CacheField>),
2596    Map(Vec<(&'static str, CacheField)>),
2597}
2598
2599impl CacheField {
2600    fn count(value: Option<u64>) -> Self {
2601        value.map_or(Self::Null, Self::Count)
2602    }
2603}
2604
2605/// One cache-status row as fields: what every row carries, what its state adds, and the
2606/// content sidecar beside it.
2607fn cache_row(status: &crate::CacheStatus) -> CacheField {
2608    use crate::CacheState;
2609
2610    let mut fields = vec![("path", CacheField::Text(status.path.to_string_lossy().into_owned()))];
2611    if let Some((encoding, hex)) = raw_os_identity(status.path.as_os_str()) {
2612        fields.push((
2613            "path_raw",
2614            CacheField::Map(vec![
2615                ("encoding", CacheField::Text(encoding.to_string())),
2616                ("hex", CacheField::Text(hex)),
2617            ]),
2618        ));
2619    }
2620    fields.extend([
2621        ("bytes", CacheField::Count(status.bytes)),
2622        ("state", CacheField::Text(status.state.label().to_string())),
2623    ]);
2624    match &status.state {
2625        CacheState::Current(info) => {
2626            fields.push(("root", CacheField::Text(info.root.to_string_lossy().into_owned())));
2627            if let Some((encoding, hex)) = raw_os_identity(info.root.as_os_str()) {
2628                fields.push((
2629                    "root_raw",
2630                    CacheField::Map(vec![
2631                        ("encoding", CacheField::Text(encoding.to_string())),
2632                        ("hex", CacheField::Text(hex)),
2633                    ]),
2634                ));
2635            }
2636            fields.push(("entries", CacheField::Count(info.entries)));
2637            fields.push(("identity", snapshot_identity_field(info.identity)));
2638        }
2639        CacheState::Stale(reason) => fields.extend(stale_fields(*reason)),
2640        CacheState::Leftover(kind) => {
2641            fields.push(("leftover_kind", CacheField::Text(kind.label().to_string())));
2642        }
2643        CacheState::Unrecognized | CacheState::Absent => {}
2644    }
2645    // Every row carries it, whatever the state, so a consumer reads one shape rather than
2646    // discovering which keys this row happens to have.
2647    fields.push(("content", status.content.as_ref().map_or(CacheField::Null, content_field)));
2648    CacheField::Map(fields)
2649}
2650
2651/// Why a store is stale, and the format version when that is the reason.
2652fn stale_fields(reason: crate::StaleReason) -> [(&'static str, CacheField); 2] {
2653    [
2654        ("stale_reason", CacheField::Text(reason.label().to_string())),
2655        ("format_version", CacheField::count(reason.format_version().map(u64::from))),
2656    ]
2657}
2658
2659/// The content sidecar beside a snapshot: its size and state, and a current one's identity
2660/// and record count.
2661fn content_field(content: &crate::ContentStatus) -> CacheField {
2662    use crate::ContentState;
2663
2664    let mut fields = vec![
2665        ("bytes", CacheField::Count(content.bytes)),
2666        ("state", CacheField::Text(content.state.label().to_string())),
2667    ];
2668    match &content.state {
2669        ContentState::Current(info) => {
2670            fields.push(("records", CacheField::Count(info.records)));
2671            fields.push(("identity", content_identity_field(&info.identity)));
2672        }
2673        ContentState::Stale(reason) => fields.extend(stale_fields(*reason)),
2674    }
2675    CacheField::Map(fields)
2676}
2677
2678/// A snapshot's tier identities: its entry tier, and its `.gitignore` control tier as the
2679/// report's `ignore_rules` names it, `null` when no rule was read.
2680fn snapshot_identity_field(identity: crate::SnapshotIdentity) -> CacheField {
2681    let ignore_rules = match identity.controls {
2682        crate::ControlTierIdentity::NotObserved => CacheField::Null,
2683        crate::ControlTierIdentity::Observed { limits } => {
2684            let limit = |limit: Option<usize>| {
2685                CacheField::count(limit.map(|limit| u64::try_from(limit).unwrap_or(u64::MAX)))
2686            };
2687            CacheField::Map(vec![(
2688                "limits",
2689                CacheField::Map(vec![
2690                    ("budget", limit(limits.budget)),
2691                    ("line_limit", limit(limits.line_limit)),
2692                ]),
2693            )])
2694        }
2695    };
2696    CacheField::Map(vec![
2697        ("entries", entry_identity_field(identity.entries)),
2698        ("ignore_rules", ignore_rules),
2699    ])
2700}
2701
2702/// An entry tier's identity: the engine that built it, the scope fields, and the type-rules
2703/// and reducer-set fingerprints.
2704fn entry_identity_field(identity: crate::EntryTierIdentity) -> CacheField {
2705    let scope = identity.scope;
2706    let depth = scope.max_depth.map(|depth| u64::try_from(depth).unwrap_or(u64::MAX));
2707    CacheField::Map(vec![
2708        ("engine", CacheField::Count(identity.engine)),
2709        ("max_depth", CacheField::count(depth)),
2710        ("follow_symlinks", CacheField::Bool(scope.follow_symlinks)),
2711        ("one_filesystem", CacheField::Bool(scope.one_filesystem)),
2712        ("hidden_fingerprint", CacheField::Count(scope.hidden_fingerprint)),
2713        ("exclude_special", CacheField::Bool(scope.exclude_special)),
2714        ("population", CacheField::Text(scope.population.label().to_string())),
2715        ("control_fingerprint", CacheField::Count(scope.control_fingerprint)),
2716        ("type_rules_fingerprint", CacheField::Count(identity.type_rules_fingerprint)),
2717        ("reducers_fingerprint", CacheField::Count(identity.reducers_fingerprint)),
2718    ])
2719}
2720
2721/// A content tier's identity: the entry tier it was analyzed over, which holds its type
2722/// rules, then the analyzer set, options, and analyzers under the names a report's
2723/// `analysis` object gives them.
2724fn content_identity_field(identity: &crate::ContentTierIdentity) -> CacheField {
2725    let analyze = analysis_set_labels(identity.analysis)
2726        .into_iter()
2727        .map(|label| CacheField::Text(label.to_string()))
2728        .collect();
2729    let analyzers = identity
2730        .provenance
2731        .analyzers
2732        .iter()
2733        .map(|(id, version)| {
2734            CacheField::Map(vec![
2735                ("id", CacheField::Text(id.0.to_string())),
2736                ("version", CacheField::Count(u64::from(version.0))),
2737            ])
2738        })
2739        .collect();
2740    CacheField::Map(vec![
2741        ("entries", entry_identity_field(identity.entries)),
2742        ("analyze", CacheField::List(analyze)),
2743        ("options_fingerprint", CacheField::Count(identity.provenance.options_fingerprint.0)),
2744        ("analyzers", CacheField::List(analyzers)),
2745    ])
2746}
2747
2748/// The human cache-status layout: one line per file, then what can be done about the
2749/// files this build cannot use.
2750fn render_cache_status_text(
2751    statuses: &[crate::CacheStatus],
2752    scope: crate::CacheScope,
2753    color: bool,
2754) -> String {
2755    use crate::{CacheScope, CacheState, LeftoverKind, StaleReason};
2756
2757    let mut lines = Vec::new();
2758    let mut current = 0_usize;
2759    let (mut stale, mut stale_bytes) = (0_usize, 0_u64);
2760    let (mut leftover, mut leftover_bytes, mut staging) = (0_usize, 0_u64, 0_usize);
2761    let (mut unrecognized, mut unrecognized_bytes) = (0_usize, 0_u64);
2762    for status in statuses {
2763        let content_bytes = status.content_bytes().unwrap_or(0);
2764        match &status.state {
2765            CacheState::Current(info) => {
2766                current += 1;
2767                // A sidecar this build cannot serve is named, so the bytes are not read as a
2768                // usable content cache.
2769                let stale_content = status
2770                    .content
2771                    .as_ref()
2772                    .is_some_and(|content| matches!(content.state, crate::ContentState::Stale(_)));
2773                lines.push(format!(
2774                    "{}  {} entries, {} metadata, {} {}content  {}",
2775                    status.path.display(),
2776                    human_count(info.entries),
2777                    styled_bytes(status.bytes, 0, color, false),
2778                    styled_bytes(content_bytes, 0, color, false),
2779                    if stale_content { "stale " } else { "" },
2780                    info.root.display()
2781                ));
2782            }
2783            CacheState::Stale(reason) => {
2784                stale += 1;
2785                stale_bytes =
2786                    stale_bytes.saturating_add(status.bytes).saturating_add(content_bytes);
2787                let why = match reason {
2788                    StaleReason::OlderFormat { version } => {
2789                        format!("older snapshot format {version}")
2790                    }
2791                    StaleReason::NewerFormat { version } => {
2792                        format!("newer snapshot format {version}")
2793                    }
2794                    StaleReason::OtherEngine => "written by another fdu version".to_string(),
2795                    StaleReason::Unreadable => "unreadable by this build".to_string(),
2796                };
2797                lines.push(format!(
2798                    "{}  stale {}, {} metadata, {} content",
2799                    status.path.display(),
2800                    detail(&format!("({why})"), color),
2801                    styled_bytes(status.bytes, 0, color, false),
2802                    styled_bytes(content_bytes, 0, color, false)
2803                ));
2804            }
2805            CacheState::Leftover(kind) => {
2806                leftover += 1;
2807                leftover_bytes = leftover_bytes.saturating_add(status.bytes);
2808                let what = match kind {
2809                    LeftoverKind::StagingTemporary => {
2810                        staging += 1;
2811                        "staging temporary"
2812                    }
2813                    LeftoverKind::OrphanedContent => "orphaned content sidecar",
2814                };
2815                lines.push(format!(
2816                    "{}  leftover {}, {}",
2817                    status.path.display(),
2818                    detail(&format!("({what})"), color),
2819                    styled_bytes(status.bytes, 0, color, false)
2820                ));
2821            }
2822            CacheState::Unrecognized => {
2823                unrecognized += 1;
2824                unrecognized_bytes = unrecognized_bytes.saturating_add(status.bytes);
2825                lines.push(format!(
2826                    "{}  unrecognized, {}",
2827                    status.path.display(),
2828                    styled_bytes(status.bytes, 0, color, false)
2829                ));
2830            }
2831            // Root scope synthesises a status for the path a snapshot *would* occupy, so a
2832            // tree that has never been cached yields one absent entry. Absence is not a
2833            // file to describe.
2834            CacheState::Absent => {}
2835        }
2836    }
2837    if lines.is_empty() {
2838        return "No cached snapshots.".to_string();
2839    }
2840
2841    if stale > 0 {
2842        let (subject, object) = if stale == 1 {
2843            ("1 stale snapshot".to_string(), "it")
2844        } else {
2845            (format!("{} stale snapshots", human_count_u128(stale as u128)), "them")
2846        };
2847        let remedy = match scope {
2848            CacheScope::Root => format!("fdu --cache-clear PATH removes {object}"),
2849            CacheScope::All if current == 0 => format!("fdu --cache-clear=all removes {object}"),
2850            CacheScope::All => {
2851                format!("fdu --cache-clear=all removes {object}, along with every current snapshot")
2852            }
2853        };
2854        lines.push(format!(
2855            "{subject} {} cannot be served by this build; {remedy}.",
2856            cache_size_detail(stale_bytes, color)
2857        ));
2858    }
2859    if leftover > 0 {
2860        // Named as fdu's own, because they are: calling them foreign would tell the user
2861        // to leave fdu's debris alone. `=all` is the scope that reclaims them; a root's
2862        // clear reaches only the one path that root's snapshot occupies.
2863        let (subject, predicate, object) = if leftover == 1 {
2864            ("1 leftover file".to_string(), "is", "it")
2865        } else {
2866            (format!("{} leftover files", human_count_u128(leftover as u128)), "are", "them")
2867        };
2868        // A staging file is reclaimed only once it is too old to belong to a running
2869        // writer, and a status knows no file's age, so the promise names the exception
2870        // rather than counting files the clear will then decline and explain.
2871        let caveat = if staging > 0 {
2872            ", though a staging file waits until it is too old to be a running writer's"
2873        } else {
2874            ""
2875        };
2876        lines.push(format!(
2877            "{subject} {} {predicate} fdu's own, left by an interrupted \
2878             write; fdu --cache-clear=all reclaims {object}{caveat}.",
2879            cache_size_detail(leftover_bytes, color)
2880        ));
2881    }
2882    if unrecognized > 0 {
2883        let (subject, predicate, object) = if unrecognized == 1 {
2884            ("1 unrecognized file".to_string(), "is not an fdu snapshot", "it")
2885        } else {
2886            (
2887                format!("{} unrecognized files", human_count_u128(unrecognized as u128)),
2888                "are not fdu snapshots",
2889                "them",
2890            )
2891        };
2892        lines.push(format!(
2893            "{subject} {} {predicate}, so fdu leaves {object} in place.",
2894            cache_size_detail(unrecognized_bytes, color)
2895        ));
2896    }
2897    lines.join("\n")
2898}
2899
2900fn cache_size_detail(bytes: u64, color: bool) -> String {
2901    format!("{}{}{}", detail("(", color), styled_bytes(bytes, 0, color, true), detail(")", color))
2902}
2903
2904#[cfg(test)]
2905mod tests {
2906    fn render(report: &Report, format: Format, color: bool) -> String {
2907        super::render(report, format, color).expect("compatible report format")
2908    }
2909
2910    use super::*;
2911    use crate::Index;
2912    use crate::engine_contract::{Attrs, Observation, Op, ScanScope};
2913    use crate::query::{Bound, Query, Request, Selection, ShareThreshold};
2914    use std::ffi::OsStr;
2915    use std::path::PathBuf;
2916    use std::process::{Command, Stdio};
2917    use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
2918
2919    struct Provenance {
2920        scan_started_at: Option<SystemTime>,
2921        generated_at: SystemTime,
2922        source: ReportSource,
2923        complete: bool,
2924        errors: Vec<String>,
2925    }
2926
2927    fn report(index: &Index, request: &Request, provenance: &Provenance) -> crate::Result<Report> {
2928        let mut report = crate::query::report(index, request, provenance.generated_at)?;
2929        report.provenance.scan_started_at = provenance.scan_started_at;
2930        report.provenance.source = provenance.source;
2931        report.status.complete = provenance.complete;
2932        report.status.errors = provenance
2933            .errors
2934            .iter()
2935            .cloned()
2936            .map(|message| crate::Issue::provider_failure(None, message))
2937            .collect();
2938        Ok(report)
2939    }
2940
2941    fn attrs(size: u64, mtime_ns: i64) -> Attrs {
2942        Attrs {
2943            size,
2944            allocated: size.div_ceil(512) * 512,
2945            mtime_ns,
2946            ctime_ns: mtime_ns,
2947            inode: 7,
2948            dev: 1,
2949        }
2950    }
2951
2952    fn cache_file(name: &str, bytes: u64, state: crate::CacheState) -> crate::CacheStatus {
2953        // A stale snapshot here keeps the sidecar an older format wrote beside it.
2954        let content =
2955            matches!(state, crate::CacheState::Stale(_)).then_some(crate::ContentStatus {
2956                bytes: 5,
2957                state: crate::ContentState::Stale(crate::StaleReason::OlderFormat { version: 4 }),
2958            });
2959        crate::CacheStatus { path: PathBuf::from(name), bytes, content, state }
2960    }
2961
2962    /// A snapshot identity with a small value in every field, so a rendering is readable.
2963    fn small_snapshot_identity() -> crate::SnapshotIdentity {
2964        crate::SnapshotIdentity {
2965            entries: crate::EntryTierIdentity {
2966                engine: 1,
2967                scope: crate::EntryScope {
2968                    max_depth: None,
2969                    follow_symlinks: false,
2970                    one_filesystem: true,
2971                    hidden_fingerprint: 2,
2972                    exclude_special: false,
2973                    population: IgnoredEntries::Include,
2974                    control_fingerprint: 0,
2975                },
2976                type_rules_fingerprint: 3,
2977                reducers_fingerprint: 4,
2978            },
2979            controls: crate::ControlTierIdentity::Observed {
2980                limits: crate::control::ControlLimits { budget: Some(10), line_limit: None },
2981            },
2982        }
2983    }
2984
2985    /// Stale, leftover, and unrecognized files are shown, sized, and followed by what
2986    /// reclaims them.
2987    ///
2988    /// A unit test beside the goldens because a golden cannot produce a newer format or
2989    /// every reason at once, and because the remedy depends on the scope and on whether
2990    /// clearing would also take current snapshots.
2991    #[test]
2992    fn cache_status_shows_stale_and_unrecognized_files_with_their_remedy() {
2993        use crate::{CacheScope, CacheState, LeftoverKind, StaleReason};
2994
2995        let stale = [
2996            cache_file("a.fdu", 10, CacheState::Stale(StaleReason::OlderFormat { version: 2 })),
2997            cache_file("b.fdu", 20, CacheState::Stale(StaleReason::NewerFormat { version: 99 })),
2998            cache_file("c.fdu", 30, CacheState::Stale(StaleReason::OtherEngine)),
2999            cache_file("d.fdu", 40, CacheState::Stale(StaleReason::Unreadable)),
3000            cache_file("notes.txt", 14, CacheState::Unrecognized),
3001        ];
3002        assert_eq!(
3003            render_cache_status(&stale, CacheScope::All, Format::Text),
3004            "a.fdu  stale (older snapshot format 2), 10 B metadata, 5 B content\n\
3005             b.fdu  stale (newer snapshot format 99), 20 B metadata, 5 B content\n\
3006             c.fdu  stale (written by another fdu version), 30 B metadata, 5 B content\n\
3007             d.fdu  stale (unreadable by this build), 40 B metadata, 5 B content\n\
3008             notes.txt  unrecognized, 14 B\n\
3009             4 stale snapshots (120 B) cannot be served by this build; \
3010             fdu --cache-clear=all removes them.\n\
3011             1 unrecognized file (14 B) is not an fdu snapshot, so fdu leaves it in place."
3012        );
3013        let colored = render_cache_status_with_options(
3014            &stale,
3015            CacheScope::All,
3016            Format::Text,
3017            RenderOptions { color: true, ..RenderOptions::default() },
3018        );
3019        assert!(colored.contains(&detail("(older snapshot format 2)", true)), "{colored:?}");
3020        assert!(
3021            colored.contains(&format!("4 stale snapshots {} cannot", cache_size_detail(120, true))),
3022            "{colored:?}"
3023        );
3024        assert_eq!(
3025            strip_ansi(&colored),
3026            render_cache_status(&stale, CacheScope::All, Format::Text)
3027        );
3028
3029        // fdu's own debris is named as fdu's, so a reader is not told to leave it alone.
3030        let leftovers = [
3031            cache_file(
3032                ".g.fdu.tmp.1.2.3",
3033                60,
3034                CacheState::Leftover(LeftoverKind::StagingTemporary),
3035            ),
3036            cache_file("h.analysis.bin", 70, CacheState::Leftover(LeftoverKind::OrphanedContent)),
3037        ];
3038        assert_eq!(
3039            render_cache_status(&leftovers, CacheScope::All, Format::Text),
3040            ".g.fdu.tmp.1.2.3  leftover (staging temporary), 60 B\n\
3041             h.analysis.bin  leftover (orphaned content sidecar), 70 B\n\
3042             2 leftover files (130 B) are fdu's own, left by an interrupted write; \
3043             fdu --cache-clear=all reclaims them, though a staging file waits until it is \
3044             too old to be a running writer's."
3045        );
3046        assert!(render_cache_status(&leftovers[..1], CacheScope::Root, Format::Text).ends_with(
3047            "1 leftover file (60 B) is fdu's own, left by an interrupted write; \
3048                 fdu --cache-clear=all reclaims it, though a staging file waits until it \
3049                 is too old to be a running writer's."
3050        ));
3051        // With no staging file listed, nothing is held back and the promise is plain: a
3052        // status that named an exception with no file it could apply to would be noise.
3053        assert!(render_cache_status(&leftovers[1..], CacheScope::All, Format::Text).ends_with(
3054            "1 leftover file (70 B) is fdu's own, left by an interrupted write; \
3055                 fdu --cache-clear=all reclaims it."
3056        ));
3057
3058        let root = [cache_file("a.fdu", 10, CacheState::Stale(StaleReason::OtherEngine))];
3059        assert!(render_cache_status(&root, CacheScope::Root, Format::Text).ends_with(
3060            "1 stale snapshot (15 B) cannot be served by this build; \
3061                 fdu --cache-clear PATH removes it."
3062        ));
3063
3064        let current = cache_file(
3065            "e.fdu",
3066            50,
3067            CacheState::Current(crate::SnapshotInfo {
3068                root: PathBuf::from("/tree"),
3069                identity: small_snapshot_identity(),
3070                entries: 3,
3071            }),
3072        );
3073        let mixed = [stale[0].clone(), current, stale[4].clone(), stale[4].clone()];
3074        assert!(render_cache_status(&mixed, CacheScope::All, Format::Text).ends_with(
3075            "e.fdu  3 entries, 50 B metadata, 0 B content  /tree\n\
3076             notes.txt  unrecognized, 14 B\n\
3077             notes.txt  unrecognized, 14 B\n\
3078             1 stale snapshot (15 B) cannot be served by this build; \
3079             fdu --cache-clear=all removes it, along with every current snapshot.\n\
3080             2 unrecognized files (28 B) are not fdu snapshots, so fdu leaves them in place."
3081        ));
3082
3083        let absent = [cache_file("f.fdu", 0, CacheState::Absent)];
3084        assert_eq!(
3085            render_cache_status(&absent, CacheScope::Root, Format::Text),
3086            "No cached snapshots."
3087        );
3088        // Every row carries the same keys whatever its state, and the envelope line
3089        // carries the schema even when nothing follows it.
3090        assert_eq!(
3091            render_cache_status(
3092                &[stale[0].clone(), absent[0].clone(), stale[4].clone(), leftovers[0].clone()],
3093                CacheScope::All,
3094                Format::Jsonl
3095            ),
3096            "{\"schema\": \"fdu.cache/3\"}\n\
3097             {\"path\": \"a.fdu\", \"bytes\": 10, \"state\": \"stale\", \"stale_reason\": \"older_format\", \"format_version\": 2, \"content\": {\"bytes\": 5, \"state\": \"stale\", \"stale_reason\": \"older_format\", \"format_version\": 4}}\n\
3098             {\"path\": \"f.fdu\", \"bytes\": 0, \"state\": \"absent\", \"content\": null}\n\
3099             {\"path\": \"notes.txt\", \"bytes\": 14, \"state\": \"unrecognized\", \"content\": null}\n\
3100             {\"path\": \".g.fdu.tmp.1.2.3\", \"bytes\": 60, \"state\": \"leftover\", \"leftover_kind\": \"staging_temporary\", \"content\": null}"
3101        );
3102        assert_eq!(
3103            render_cache_status(&[], CacheScope::All, Format::Jsonl),
3104            "{\"schema\": \"fdu.cache/3\"}"
3105        );
3106        assert_eq!(
3107            render_cache_status(&[], CacheScope::All, Format::Json),
3108            "{\n  \"schema\": \"fdu.cache/3\",\n  \"caches\": []\n}\n"
3109        );
3110        // An empty sequence in both formats: a bare `caches:` is YAML null, and a reader
3111        // of one schema should not have to tell null from a list it can iterate.
3112        assert_eq!(
3113            render_cache_status(&[], CacheScope::All, Format::Yaml),
3114            "schema: fdu.cache/3\ncaches: []\n"
3115        );
3116        assert!(
3117            render_cache_status(&stale[2..3], CacheScope::All, Format::Json)
3118                .starts_with("{\n  \"schema\": \"fdu.cache/3\",\n  \"caches\": [\n    {")
3119        );
3120        assert!(
3121            render_cache_status(&stale[2..3], CacheScope::All, Format::Yaml)
3122                .starts_with("schema: fdu.cache/3\ncaches:\n  -\n    path: c.fdu")
3123        );
3124        assert!(
3125            render_cache_status(&stale[2..3], CacheScope::All, Format::Yaml).ends_with(
3126                "state: stale\n    stale_reason: other_engine\n    format_version: null\n    \
3127                 content:\n      bytes: 5\n      state: stale\n      stale_reason: older_format\n      \
3128                 format_version: 4\n"
3129            )
3130        );
3131        assert!(render_cache_status(&leftovers[1..], CacheScope::All, Format::Yaml).ends_with(
3132            "state: leftover\n    leftover_kind: orphaned_content\n    content: null\n"
3133        ));
3134    }
3135
3136    /// A current snapshot carries the identity of every tier it holds, and the sidecar
3137    /// beside it its own, nested the same way in JSON and YAML: the entry tier, then the
3138    /// control tier as the report's `ignore_rules` names it, and for content its entry tier,
3139    /// which alone holds the type rules, then the analyzer set, options, and analyzers under
3140    /// the names a report's `analysis` object gives them.
3141    #[test]
3142    fn cache_status_carries_every_stored_tier_identity() {
3143        use crate::{CacheScope, CacheState, ContentInfo, ContentState, ContentStatus};
3144
3145        let snapshot = small_snapshot_identity();
3146        let content = crate::ContentTierIdentity {
3147            entries: snapshot.entries,
3148            analysis: crate::content::AnalysisSet::NONE.with_lines(),
3149            provenance: crate::AnalyzerProvenance {
3150                options_fingerprint: crate::content::OptionsFingerprint(5),
3151                analyzers: vec![(
3152                    crate::content::CONTENT_BASIC,
3153                    crate::content::AnalyzerVersion(1),
3154                )],
3155            },
3156        };
3157        let status = crate::CacheStatus {
3158            path: PathBuf::from("e.fdu"),
3159            bytes: 50,
3160            content: Some(ContentStatus {
3161                bytes: 9,
3162                state: ContentState::Current(ContentInfo { identity: content, records: 2 }),
3163            }),
3164            state: CacheState::Current(crate::SnapshotInfo {
3165                root: PathBuf::from("/tree"),
3166                identity: snapshot,
3167                entries: 3,
3168            }),
3169        };
3170        let entries = "{\"engine\": 1, \"max_depth\": null, \"follow_symlinks\": false, \
3171                       \"one_filesystem\": true, \"hidden_fingerprint\": 2, \"exclude_special\": false, \
3172                       \"population\": \"include\", \"control_fingerprint\": 0, \
3173                       \"type_rules_fingerprint\": 3, \"reducers_fingerprint\": 4}";
3174        assert_eq!(
3175            render_cache_status(std::slice::from_ref(&status), CacheScope::Root, Format::Jsonl),
3176            format!(
3177                "{{\"schema\": \"fdu.cache/3\"}}\n\
3178                 {{\"path\": \"e.fdu\", \"bytes\": 50, \"state\": \"current\", \"root\": \"/tree\", \
3179                 \"entries\": 3, \"identity\": {{\"entries\": {entries}, \"ignore_rules\": \
3180                 {{\"limits\": {{\"budget\": 10, \"line_limit\": null}}}}}}, \"content\": {{\"bytes\": 9, \
3181                 \"state\": \"current\", \"records\": 2, \"identity\": {{\"entries\": {entries}, \
3182                 \"analyze\": [\"lines\"], \"options_fingerprint\": 5, \
3183                 \"analyzers\": [{{\"id\": \"content-basic-v1\", \"version\": 1}}]}}}}}}"
3184            )
3185        );
3186        let entries = "\n          engine: 1\n          max_depth: null\n          \
3187                       follow_symlinks: false\n          one_filesystem: true\n          \
3188                       hidden_fingerprint: 2\n          exclude_special: false\n          \
3189                       population: include\n          control_fingerprint: 0\n          \
3190                       type_rules_fingerprint: 3\n          reducers_fingerprint: 4";
3191        assert_eq!(
3192            render_cache_status(std::slice::from_ref(&status), CacheScope::Root, Format::Yaml),
3193            format!(
3194                "schema: fdu.cache/3\ncaches:\n  -\n    path: e.fdu\n    bytes: 50\n    state: current\n    \
3195                 root: /tree\n    entries: 3\n    identity:\n      entries:{}\n      \
3196                 ignore_rules:\n        limits:\n          budget: 10\n          line_limit: null\n    \
3197                 content:\n      bytes: 9\n      state: current\n      records: 2\n      identity:\n        \
3198                 entries:{entries}\n        analyze:\n          - lines\n        \
3199                 options_fingerprint: 5\n        analyzers:\n          -\n            id: content-basic-v1\n            \
3200                 version: 1\n",
3201                entries.replace("\n  ", "\n")
3202            )
3203        );
3204        let mut large = status.clone();
3205        large.bytes = 60_696_111;
3206        large.content.as_mut().expect("fixture has a content sidecar").bytes = 120_783_062;
3207        assert_eq!(
3208            render_cache_status(std::slice::from_ref(&large), CacheScope::Root, Format::Text),
3209            "e.fdu  3 entries, 57 MiB metadata, 115 MiB content  /tree"
3210        );
3211        let colored = render_cache_status_with_options(
3212            std::slice::from_ref(&large),
3213            CacheScope::Root,
3214            Format::Text,
3215            RenderOptions { color: true, ..RenderOptions::default() },
3216        );
3217        assert_eq!(
3218            strip_ansi(&colored),
3219            render_cache_status(&[large], CacheScope::Root, Format::Text)
3220        );
3221        let zero = render_cache_status_with_options(
3222            std::slice::from_ref(&status),
3223            CacheScope::Root,
3224            Format::Text,
3225            RenderOptions { color: true, ..RenderOptions::default() },
3226        );
3227        assert!(!zero.contains("\x1b["), "positive sub-GiB sizes stay unstyled: {zero:?}");
3228        let mut zero_and_gib = status.clone();
3229        zero_and_gib.bytes = 0;
3230        zero_and_gib.content.as_mut().expect("fixture has a content sidecar").bytes = 1 << 30;
3231        let colored = render_cache_status_with_options(
3232            std::slice::from_ref(&zero_and_gib),
3233            CacheScope::Root,
3234            Format::Text,
3235            RenderOptions { color: true, ..RenderOptions::default() },
3236        );
3237        assert!(colored.contains(&paint("0 B", STYLE_DETAIL, true)), "{colored:?}");
3238        assert!(colored.contains(&paint("1.0 GiB", AnsiStyle::new().bold(), true)), "{colored:?}");
3239        assert_eq!(strip_ansi(&colored), "e.fdu  3 entries, 0 B metadata, 1.0 GiB content  /tree");
3240        for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3241            assert_eq!(
3242                render_cache_status_with_options(
3243                    std::slice::from_ref(&status),
3244                    CacheScope::Root,
3245                    format,
3246                    RenderOptions { color: true, ..RenderOptions::default() },
3247                ),
3248                render_cache_status(std::slice::from_ref(&status), CacheScope::Root, format)
3249            );
3250        }
3251        // A sidecar this build cannot serve is named in text too.
3252        let stale_content = crate::CacheStatus {
3253            content: Some(ContentStatus {
3254                bytes: 9,
3255                state: ContentState::Stale(crate::StaleReason::OtherEngine),
3256            }),
3257            ..status
3258        };
3259        assert_eq!(
3260            render_cache_status(&[stale_content], CacheScope::Root, Format::Text),
3261            "e.fdu  3 entries, 50 B metadata, 9 B stale content  /tree"
3262        );
3263    }
3264
3265    /// The cache schema is a promise, like the report schema beside it.
3266    ///
3267    /// Fails loudly when the string moves, so the field rename this constant was added
3268    /// for — `recognized` to `state` — cannot happen again without a version to key on.
3269    #[test]
3270    fn the_cache_schema_constant_is_the_versioning_promise() {
3271        assert_eq!(CACHE_SCHEMA, "fdu.cache/3");
3272        for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3273            let rendered = render_cache_status(&[], crate::CacheScope::All, format);
3274            assert!(rendered.contains(CACHE_SCHEMA), "{format:?} carries no schema: {rendered}");
3275        }
3276    }
3277
3278    #[cfg(all(unix, feature = "watch"))]
3279    #[test]
3280    fn cache_and_change_rows_preserve_non_unicode_raw_paths() {
3281        use std::ffi::OsString;
3282        use std::os::unix::ffi::OsStringExt;
3283
3284        let path = PathBuf::from(OsString::from_vec(vec![b'n', 0x80]));
3285        let status = crate::CacheStatus {
3286            path: path.clone(),
3287            bytes: 3,
3288            content: None,
3289            state: crate::CacheState::Unrecognized,
3290        };
3291        for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3292            let rendered =
3293                render_cache_status(std::slice::from_ref(&status), crate::CacheScope::All, format);
3294            assert!(rendered.contains("path_raw"), "{format:?}: {rendered}");
3295            assert!(rendered.contains("6e80"), "{format:?}: {rendered}");
3296        }
3297
3298        let change = crate::Change {
3299            path,
3300            kind: crate::ChangeKind::Remove,
3301            entry_kind: None,
3302            bytes: None,
3303            allocated: None,
3304            mtime_ns: None,
3305            ignored: None,
3306            clock: 1,
3307        };
3308        for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3309            let rendered = render_change(&change, format);
3310            assert!(rendered.contains("path_raw"), "{format:?}: {rendered}");
3311            assert!(rendered.contains("6e80"), "{format:?}: {rendered}");
3312        }
3313    }
3314
3315    /// A bound states itself, and the count it states is the count it dropped.
3316    ///
3317    /// The second half is why this is a unit test: a golden fixture is too small for a
3318    /// wrong total to look wrong, and the defect being guarded against — twenty rows of
3319    /// 192,871 presented as everything — only shows at a scale goldens do not reach.
3320    #[test]
3321    fn a_bound_states_itself_and_states_it_accurately() {
3322        let report = fixture(&[ViewSpec::Files]);
3323        let Section::Files { rows, total, .. } = &report.sections[0] else {
3324            panic!("expected a files section");
3325        };
3326        let full = rows.len();
3327        assert_eq!(*total, full, "an unbounded view drops nothing");
3328        assert!(!render(&report, Format::Text, false).contains("--limit all"), "and says nothing");
3329        assert!(render(&report, Format::Json, false).contains("\"bound\": null"));
3330
3331        // Now bound it to one row and check the report agrees with reality.
3332        let mut query = Query { views: vec![ViewSpec::Files], ..Query::default() };
3333        query.selection.limit = Some(Bound::Limit(1));
3334        let bounded = fixture_for(&query);
3335        let Section::Files { rows, total, .. } = &bounded.sections[0] else {
3336            panic!("expected a files section");
3337        };
3338        assert_eq!(rows.len(), 1);
3339        assert_eq!(*total, full, "the total is what there was, not what was kept");
3340
3341        let text = render(&bounded, Format::Text, false);
3342        assert!(!text.contains("of {full}"), "a single view states its bound in a note: {text}");
3343        assert!(!text.contains("--limit"), "actionable guidance stays out of the body: {text}");
3344        assert!(
3345            report_notes(&bounded)
3346                .contains(&format!("note: display limits: 1 of {full} rows shown")),
3347            "the epilogue states the bound: {:?}",
3348            report_notes(&bounded)
3349        );
3350        assert_eq!(
3351            report_tips(&bounded),
3352            vec!["tip: show more: limit=all"],
3353            "the shared epilogue names the row remedy once"
3354        );
3355        let json = render(&bounded, Format::Json, false);
3356        assert!(json.contains(&format!("\"shown\": 1, \"total\": {full}")), "{json:.200}");
3357        let yaml = render(&bounded, Format::Yaml, false);
3358        assert!(yaml.contains("shown: 1"), "{yaml:.200}");
3359    }
3360
3361    /// Colour must not move anything.
3362    ///
3363    /// The golden suite structurally cannot check this: it runs under `NO_COLOR=1` and
3364    /// only ever sees the uncoloured form, which is exactly how the extensions view
3365    /// shipped misaligned — `{:<12}` counted the escape sequences toward the field width,
3366    /// so the padding collapsed the moment colour was on and every golden still passed.
3367    fn strip_ansi(text: &str) -> String {
3368        let mut out = String::with_capacity(text.len());
3369        let mut chars = text.chars();
3370        while let Some(c) = chars.next() {
3371            if c == '\u{1b}' {
3372                for escape in chars.by_ref() {
3373                    if escape.is_ascii_alphabetic() {
3374                        break;
3375                    }
3376                }
3377            } else {
3378                out.push(c);
3379            }
3380        }
3381        out
3382    }
3383
3384    #[test]
3385    fn colour_never_changes_the_layout_of_any_view() {
3386        for view in [
3387            ViewSpec::Tree,
3388            ViewSpec::Extensions,
3389            ViewSpec::Types,
3390            ViewSpec::Families,
3391            ViewSpec::Languages,
3392            ViewSpec::Files,
3393            ViewSpec::Summary,
3394        ] {
3395            let report = fixture(&[view]);
3396            let plain = render(&report, Format::Text, false);
3397            let coloured = render(&report, Format::Text, true);
3398            // Files is a bare listing meant for piping; this summary has no ignored
3399            // contribution to annotate. The other fixtures have a styled label.
3400            let styles_a_label = !matches!(view, ViewSpec::Files | ViewSpec::Summary);
3401            assert_eq!(
3402                plain != coloured,
3403                styles_a_label,
3404                "{view:?} disagrees with whether it styles a label"
3405            );
3406            assert_eq!(
3407                strip_ansi(&coloured).replace('·', "░").replace('▒', "█"),
3408                plain,
3409                "{view:?} lays out differently once colour is on"
3410            );
3411        }
3412    }
3413
3414    /// The rule the helper exists to enforce, stated directly.
3415    #[test]
3416    fn a_label_cell_is_measured_on_visible_text() {
3417        let plain = label_cell("md", 6, STYLE_CATEGORY, false);
3418        let coloured = label_cell("md", 6, STYLE_CATEGORY, true);
3419        assert_eq!(plain, "md    ", "four spaces of padding");
3420        assert!(coloured.starts_with('\u{1b}'), "the label is styled");
3421        assert!(coloured.ends_with("    "), "and padded by the same four: {coloured:?}");
3422        // A label at or past the width gets no padding rather than a negative one.
3423        assert_eq!(label_cell("verylonglabel", 4, STYLE_CATEGORY, false), "verylonglabel");
3424    }
3425
3426    /// Every view, so a matrix test cannot silently skip one that was added later.
3427    const ALL_TEST_VIEWS: [ViewSpec; 11] = [
3428        ViewSpec::List,
3429        ViewSpec::Tree,
3430        ViewSpec::Types,
3431        ViewSpec::Extensions,
3432        ViewSpec::Families,
3433        ViewSpec::Languages,
3434        ViewSpec::Documents,
3435        ViewSpec::Files,
3436        ViewSpec::Largest,
3437        ViewSpec::Recent,
3438        ViewSpec::Summary,
3439    ];
3440
3441    /// Check a walk against its declaration without parsing or trusting a writer.
3442    struct SchemaCheck<S> {
3443        inner: S,
3444        expected: Vec<&'static str>,
3445        next: usize,
3446        depth: usize,
3447    }
3448
3449    impl<S: Sink> SchemaCheck<S> {
3450        fn report(inner: S, lossy: bool, sections: bool) -> Self {
3451            let fields = &REPORT_FIELDS;
3452            let ordered = [
3453                fields.schema,
3454                fields.generator,
3455                fields.root,
3456                fields.root_raw,
3457                fields.age_reference_ns,
3458                fields.request,
3459                fields.status,
3460                fields.provenance,
3461                fields.ignore_rules,
3462                fields.analysis,
3463                fields.reports,
3464            ];
3465            let expected = ordered
3466                .into_iter()
3467                .filter_map(|field| {
3468                    let present = match field.presence {
3469                        Presence::Always | Presence::Nullable => true,
3470                        Presence::WhenLossy => lossy,
3471                        Presence::WhenSet => sections,
3472                        Presence::WhenAnalyzer(_) => panic!("envelope has no analyzer-owned field"),
3473                    };
3474                    present.then_some(field.name)
3475                })
3476                .collect();
3477            Self { inner, expected, next: 0, depth: 0 }
3478        }
3479    }
3480
3481    impl<S: Sink> Sink for SchemaCheck<S> {
3482        type Output = S::Output;
3483
3484        fn event(&mut self, event: Event<'_>) {
3485            match event {
3486                Event::BeginMap(_) | Event::BeginSeq(_) => self.depth += 1,
3487                Event::EndMap | Event::EndSeq => self.depth -= 1,
3488                Event::Key(name) if self.depth == 1 => {
3489                    assert_eq!(
3490                        Some(&name),
3491                        self.expected.get(self.next),
3492                        "wire field order/presence"
3493                    );
3494                    self.next += 1;
3495                }
3496                Event::Key(_) | Event::Scalar(_) => {}
3497            }
3498            self.inner.event(event);
3499        }
3500
3501        fn finish(self) -> Self::Output {
3502            assert_eq!(self.depth, 0, "unclosed collection");
3503            assert_eq!(self.next, self.expected.len(), "required field missing");
3504            self.inner.finish()
3505        }
3506    }
3507
3508    #[test]
3509    fn report_walk_obeys_declared_field_order_and_presence_for_every_writer() {
3510        for view in [ViewSpec::Summary, ViewSpec::Documents] {
3511            let report = fixture(&[view]);
3512            for sections in [false, true] {
3513                let mut json = SchemaCheck::report(JsonSink::pretty(), false, sections);
3514                emit_report(&mut json, &report, sections);
3515                assert!(!json.finish().is_empty());
3516                let mut line = SchemaCheck::report(JsonSink::line(), false, sections);
3517                emit_report(&mut line, &report, sections);
3518                assert!(!line.finish().is_empty());
3519                let mut yaml = SchemaCheck::report(YamlSink::new(), false, sections);
3520                emit_report(&mut yaml, &report, sections);
3521                assert!(!yaml.finish().is_empty());
3522            }
3523        }
3524    }
3525
3526    #[test]
3527    #[should_panic(expected = "required field missing")]
3528    fn schema_check_rejects_a_missing_required_field() {
3529        let mut check = SchemaCheck::report(JsonSink::line(), false, true);
3530        check.event(Event::BeginMap(Shape::Inline));
3531        check.event(Event::EndMap);
3532        check.finish();
3533    }
3534
3535    #[test]
3536    fn list_formats_preserve_the_default_tree_and_expose_flat_subtree_metrics() {
3537        let legacy = fixture(&[ViewSpec::Tree]);
3538        let list = fixture(&[ViewSpec::List]);
3539        assert_eq!(
3540            super::render(&legacy, Format::Text, false).expect("tree"),
3541            super::render(&list, Format::Tree, false).expect("list tree")
3542        );
3543        assert!(
3544            super::render(&list, Format::Paths, false).is_err(),
3545            "folding cannot silently become an inventory"
3546        );
3547        let mut rejected = Vec::new();
3548        assert_eq!(
3549            write(&list, Format::Paths, false, &mut rejected)
3550                .expect_err("folded projection")
3551                .kind(),
3552            io::ErrorKind::InvalidInput
3553        );
3554        assert!(rejected.is_empty(), "validate before writing any bytes");
3555        let flat = fixture_for(&Query {
3556            views: vec![ViewSpec::List],
3557            format: Format::Paths,
3558            selection: Selection {
3559                kinds: vec![EntryKind::Dir],
3560                size: SizeMetric::Apparent,
3561                ..Selection::default()
3562            },
3563            ..Query::default()
3564        });
3565        assert_eq!(super::render(&flat, Format::Paths, false).expect("paths"), "src\n");
3566        assert!(super::render(&flat, Format::Long, false).expect("long").contains("100 B"));
3567        assert!(super::render(&flat, Format::Tree, false).is_err());
3568        let Section::Files { rows, .. } = &flat.sections[0] else { panic!("flat list") };
3569        assert_eq!((rows[0].files, rows[0].dirs, rows[0].mtime_ns), (Some(1), Some(0), 10));
3570        assert_eq!(rows[0].complete, Some(true));
3571        assert!(
3572            super::render(&flat, Format::Json, false).expect("json").contains("\"complete\": true")
3573        );
3574        assert_eq!(
3575            rows[0].age_ns,
3576            flat.age_reference_ns.map(|reference| i128::from(reference) - 10)
3577        );
3578        for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3579            let wire = super::render(&flat, format, false).expect("serialization");
3580            assert!(wire.contains("age_reference_ns"));
3581            assert!(wire.contains("age_ns"));
3582        }
3583        assert_eq!(human_age(Some(-1)), "-0s");
3584        assert_eq!(human_age(Some(30 * 86400 * 1_000_000_000)), "30d");
3585        assert_eq!(human_age(None), "unknown");
3586        assert_eq!(flat_path(Path::new("a\nb\tc")), "a\\nb\\tc");
3587        // A backslash is the Windows separator; escaping it would print a path that
3588        // does not exist, so it is written as it is on every platform.
3589        assert_eq!(flat_path(Path::new("d/a\\b")), "d/a\\b");
3590        let mut stale = flat.clone();
3591        stale.provenance.source = ReportSource::CacheOnly;
3592        stale.provenance.freshness = Freshness::Stale;
3593        stale.scope.max_depth = Some(2);
3594        let notes = flat_diagnostics(&stale).join("\n");
3595        assert!(notes.contains("warn: stale answer"), "{notes}");
3596        assert!(!notes.contains("note: cache-only"), "the warning states it once: {notes}");
3597        assert!(notes.contains("note: result stale, complete"), "{notes}");
3598        assert!(notes.contains("scanned to depth 2 only"), "{notes}");
3599        assert_eq!(super::render(&stale, Format::Paths, false).expect("paths"), "src\n");
3600    }
3601
3602    /// Only an answer nothing verified carries the stale warning, it names the surface's
3603    /// own option for a fresh answer, and it sits between the notes and the tips, where a
3604    /// quiet frontend's filter keeps it (fdu-mdop).
3605    #[test]
3606    fn only_a_cache_only_answer_warns_that_it_is_stale_in_the_surface_vocabulary() {
3607        let tree = Query { views: vec![ViewSpec::Tree], ..Query::default() };
3608        let mut verified = fixture_for(&tree);
3609        for source in [ReportSource::ColdScan, ReportSource::WarmRevalidate] {
3610            verified.provenance.source = source;
3611            assert!(report_warnings(&verified).is_empty(), "{source:?} is verified");
3612            assert!(!diagnostics(&verified).iter().any(|line| line.starts_with("warn:")));
3613        }
3614        for (axes, option) in [
3615            (&crate::query::AxisNames::FLAGS, "--stale-ok"),
3616            (&crate::query::AxisNames::FIELDS, "stale_ok"),
3617        ] {
3618            let mut stale = fixture_for(&Query {
3619                axes,
3620                selection: Selection { depth: Some(Bound::Limit(0)), ..Selection::default() },
3621                ..tree.clone()
3622            });
3623            stale.provenance.source = ReportSource::CacheOnly;
3624            stale.provenance.freshness = Freshness::Stale;
3625            let warning = format!(
3626                "warn: stale answer: served from the snapshot without filesystem verification; \
3627                 drop {option} for a fresh answer"
3628            );
3629            assert_eq!(report_warnings(&stale), std::slice::from_ref(&warning));
3630            assert!(!report_notes(&stale).contains(&warning), "a note would be quieted");
3631            let lines = diagnostics(&stale);
3632            let at = lines.iter().position(|line| *line == warning).expect("warning");
3633            assert!(lines[..at].iter().all(|line| line.starts_with("note:")), "{lines:?}");
3634            assert!(lines[at + 1..].iter().all(|line| line.starts_with("tip:")), "{lines:?}");
3635            assert!(lines.first().is_some_and(|line| line.starts_with("note:")), "{lines:?}");
3636            assert!(lines.last().is_some_and(|line| line.starts_with("tip:")), "{lines:?}");
3637            let rendered = super::render(&stale, Format::Text, false).expect("text");
3638            assert!(!rendered.contains("warn:"), "stdout carries only the answer: {rendered}");
3639        }
3640    }
3641
3642    #[test]
3643    fn signed_ages_preserve_exact_endpoints_in_streaming_machine_formats() {
3644        let mut report = fixture_for(&Query {
3645            views: vec![ViewSpec::List],
3646            format: Format::Json,
3647            ..Query::default()
3648        });
3649        for (reference, modified) in [(i64::MAX, i64::MIN), (i64::MIN, i64::MAX)] {
3650            let age = i128::from(reference) - i128::from(modified);
3651            report.age_reference_ns = Some(reference);
3652            let Section::Files { rows, .. } = &mut report.sections[0] else { panic!("flat rows") };
3653            rows[0].mtime_ns = modified;
3654            rows[0].age_ns = Some(age);
3655            for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3656                let rendered = super::render(&report, format, false).expect("machine format");
3657                assert!(rendered.contains(&age.to_string()), "{format:?} lost exact signed age");
3658                let mut streamed = Vec::new();
3659                write(&report, format, false, &mut streamed).expect("streaming writer");
3660                assert_eq!(streamed, rendered.as_bytes());
3661            }
3662        }
3663    }
3664
3665    fn fixture(views: &[ViewSpec]) -> Report {
3666        fixture_for(&Query { views: views.to_vec(), ..Query::default() })
3667    }
3668
3669    fn fixture_for(query: &Query) -> Report {
3670        let mut index = Index::new_with_scope("/root", ScanScope::default());
3671        // A documents view is an answer about analyzers, so the index it is rendered from
3672        // holds one: the request model refuses that view over an index with no content tier,
3673        // whichever door the request came through. `words` and not `all`, because the code
3674        // analyzer would move the languages view's share off bytes.
3675        if query.views.contains(&ViewSpec::Documents) {
3676            index.prepare_content_analysis(crate::content::AnalysisRequest {
3677                profile: crate::content::AnalysisSet::NONE.with_words(),
3678                ..crate::content::AnalysisRequest::default()
3679            });
3680        }
3681        index
3682            .apply(&Observation::new(vec![
3683                Op::Upsert {
3684                    path: PathBuf::from("src"),
3685                    kind: EntryKind::Dir,
3686                    attrs: Attrs::default(),
3687                },
3688                Op::Upsert {
3689                    path: PathBuf::from("src/main.rs"),
3690                    kind: EntryKind::File,
3691                    attrs: attrs(100, 10),
3692                },
3693                Op::Upsert {
3694                    path: PathBuf::from("notes.md"),
3695                    kind: EntryKind::File,
3696                    attrs: attrs(20, 20),
3697                },
3698            ]))
3699            .expect("apply");
3700        report(
3701            &index,
3702            &crate::test_support::read_of(&index, query.clone()),
3703            &Provenance {
3704                scan_started_at: Some(UNIX_EPOCH + Duration::from_secs(1_786_386_151)),
3705                generated_at: UNIX_EPOCH + Duration::from_secs(1_786_386_152),
3706                source: ReportSource::ColdScan,
3707                complete: true,
3708                errors: Vec::new(),
3709            },
3710        )
3711        .expect("report")
3712    }
3713
3714    /// Whether a rendered line is a view header rather than a data row.
3715    ///
3716    /// Blank lines are excluded explicitly: `all` is vacuously true on an empty line, so
3717    /// the separator between blocks would otherwise count as a header.
3718    fn is_view_header_line(line: &str) -> bool {
3719        !line.is_empty() && line.chars().all(|c| c.is_ascii_uppercase())
3720    }
3721
3722    /// A structural check that output is well-formed JSON.
3723    ///
3724    /// Hand-written serializers earn their keep only if something proves they balance, so
3725    /// this walks the text tracking nesting depth and string state.
3726    fn is_valid_json(text: &str) -> bool {
3727        let (mut depth, mut in_string, mut escaped) = (0i32, false, false);
3728        for ch in text.chars() {
3729            if in_string {
3730                match ch {
3731                    _ if escaped => escaped = false,
3732                    '\\' => escaped = true,
3733                    '"' => in_string = false,
3734                    _ => {}
3735                }
3736                continue;
3737            }
3738            match ch {
3739                '"' => in_string = true,
3740                '{' | '[' => depth += 1,
3741                '}' | ']' => {
3742                    depth -= 1;
3743                    if depth < 0 {
3744                        return false;
3745                    }
3746                }
3747                _ => {}
3748            }
3749        }
3750        depth == 0 && !in_string
3751    }
3752
3753    /// Remove insignificant JSON whitespace without touching string contents.
3754    ///
3755    /// Exact layout is intentionally allowed to change when the structural sink changes;
3756    /// field names, values, and ordering remain part of the wire promise.
3757    fn compact_json(text: &str) -> String {
3758        let mut compact = String::with_capacity(text.len());
3759        let (mut in_string, mut escaped) = (false, false);
3760        for ch in text.chars() {
3761            if in_string {
3762                compact.push(ch);
3763                match ch {
3764                    _ if escaped => escaped = false,
3765                    '\\' => escaped = true,
3766                    '"' => in_string = false,
3767                    _ => {}
3768                }
3769            } else if ch == '"' {
3770                in_string = true;
3771                compact.push(ch);
3772            } else if !ch.is_ascii_whitespace() {
3773                compact.push(ch);
3774            }
3775        }
3776        compact
3777    }
3778
3779    /// Formats are serializations, not features: no view may lack one.
3780    ///
3781    /// Driven from `ALL_TEST_VIEWS` rather than a hand-written list, because a list is
3782    /// exactly what goes stale — `largest` and `recent` would not have been in it.
3783    #[test]
3784    fn every_view_renders_in_every_format() {
3785        for view in ALL_TEST_VIEWS {
3786            let report = fixture(&[view]);
3787            for format in [Format::Text, Format::Json, Format::Jsonl, Format::Yaml] {
3788                let rendered = render(&report, format, false);
3789                assert!(!rendered.trim().is_empty(), "{view:?} in {format:?} rendered nothing");
3790                if format != Format::Text {
3791                    assert!(
3792                        rendered.contains("\"schema\"") || rendered.contains("schema:"),
3793                        "{view:?} as {format:?} carries no schema: {rendered:.120}"
3794                    );
3795                }
3796            }
3797        }
3798    }
3799
3800    #[test]
3801    fn text_tree_restores_compact_bars_and_keeps_structural_indent_in_the_name_column() {
3802        // Apparent, so both rows print sizes of one width and the alignment below is about
3803        // the layout rather than about which sizes happen to round to the same block.
3804        let apparent = Query {
3805            views: vec![ViewSpec::Tree],
3806            selection: crate::query::Selection {
3807                size: crate::query::SizeMetric::Apparent,
3808                ..crate::query::Selection::default()
3809            },
3810            ..Query::default()
3811        };
3812        let text = render(&fixture_for(&apparent), Format::Text, false);
3813        assert_eq!(
3814            text,
3815            concat!(
3816                "██████████   100%       120 B  . 2 files\n",
3817                "████████░░    83%       100 B    src/ 1 file\n",
3818                "████████░░    83%       100 B      main.rs\n",
3819                "██░░░░░░░░    17%        20 B    notes.md\n",
3820            )
3821        );
3822
3823        let lines: Vec<&str> = text.lines().collect();
3824        assert_eq!(lines[0].find("120 B"), lines[1].find("100 B"));
3825        assert_eq!(lines[0].find('█'), lines[1].find('█'));
3826    }
3827
3828    #[test]
3829    fn language_text_uses_human_names_and_aligns_suffixes_with_color() {
3830        let mut index = Index::new_with_scope("/root", ScanScope::default());
3831        index
3832            .apply(&Observation::new(vec![
3833                Op::Upsert {
3834                    path: PathBuf::from("main.cpp"),
3835                    kind: EntryKind::File,
3836                    attrs: attrs(100, 10),
3837                },
3838                Op::Upsert {
3839                    path: PathBuf::from("main.js"),
3840                    kind: EntryKind::File,
3841                    attrs: attrs(100, 20),
3842                },
3843            ]))
3844            .expect("apply");
3845        let report = report(
3846            &index,
3847            &crate::test_support::read_of(
3848                &index,
3849                Query { views: vec![ViewSpec::Languages], ..Query::default() },
3850            ),
3851            &Provenance {
3852                scan_started_at: None,
3853                generated_at: UNIX_EPOCH,
3854                source: ReportSource::ColdScan,
3855                complete: true,
3856                errors: Vec::new(),
3857            },
3858        )
3859        .expect("report");
3860
3861        let plain = render(&report, Format::Text, false);
3862        assert!(plain.contains("C++"), "{plain}");
3863        assert!(plain.contains("JavaScript"), "{plain}");
3864        let plain_suffixes = plain
3865            .lines()
3866            .map(|line| line.find("1 file").expect("file count suffix"))
3867            .collect::<Vec<_>>();
3868        assert_eq!(plain_suffixes[0], plain_suffixes[1], "{plain}");
3869
3870        let colored = render(&report, Format::Text, true);
3871        let colored_suffixes = colored
3872            .lines()
3873            .map(|line| line.find("1 file").expect("colored file count suffix"))
3874            .collect::<Vec<_>>();
3875        assert_eq!(colored_suffixes[0], colored_suffixes[1], "{colored:?}");
3876
3877        let json = render(&report, Format::Json, false);
3878        assert!(json.contains("\"id\": \"cpp\""), "{json}");
3879        assert!(json.contains("\"id\": \"javascript\""), "{json}");
3880        assert!(!json.contains("\"id\": \"C++\""), "{json}");
3881        assert!(!json.contains("\"id\": \"JavaScript\""), "{json}");
3882    }
3883
3884    #[test]
3885    fn text_labels_a_percentage_that_is_not_a_byte_share() {
3886        let mut languages = fixture(&[ViewSpec::Languages]);
3887        if let Section::Metrics { summary, .. } = &mut languages.sections[0] {
3888            summary.share_metric = ShareMetric::CodeLines;
3889        } else {
3890            panic!("languages should be a metric section");
3891        }
3892        let text = render(&languages, Format::Text, false);
3893        assert!(!text.contains("code lines"), "the result carries no annotation: {text}");
3894        let shares = |report: &Report| {
3895            report_notes(report)
3896                .into_iter()
3897                .filter(|note| note.starts_with("note: percentages are shares of"))
3898                .collect::<Vec<_>>()
3899        };
3900        assert_eq!(shares(&languages), ["note: percentages are shares of code lines"]);
3901
3902        let Section::Metrics { summary, .. } = &mut languages.sections[0] else {
3903            unreachable!("languages should stay a metric section");
3904        };
3905        summary.share_metric = ShareMetric::AllocatedBytes;
3906        assert!(shares(&languages).is_empty(), "byte shares need no note");
3907
3908        let mut both = fixture(&[ViewSpec::Languages, ViewSpec::Documents]);
3909        for (section, metric) in
3910            both.sections.iter_mut().zip([ShareMetric::CodeLines, ShareMetric::DocumentWords])
3911        {
3912            let Section::Metrics { summary, .. } = section else {
3913                panic!("both views should be metric sections");
3914            };
3915            summary.share_metric = metric;
3916        }
3917        assert_eq!(
3918            shares(&both),
3919            ["note: percentages are shares of code lines (LANGUAGES), document words (DOCUMENTS)"],
3920            "one note names each view's denominator"
3921        );
3922
3923        // Beside a byte-share view, one non-byte denominator still names its section, so
3924        // the tree's percentages are not read as code lines.
3925        let mut beside_tree = fixture(&[ViewSpec::Tree, ViewSpec::Languages]);
3926        let Section::Metrics { summary, .. } = &mut beside_tree.sections[1] else {
3927            panic!("languages should be a metric section");
3928        };
3929        summary.share_metric = ShareMetric::CodeLines;
3930        assert_eq!(
3931            shares(&beside_tree),
3932            ["note: percentages are shares of code lines (LANGUAGES)"]
3933        );
3934
3935        // Sections sharing a denominator are listed under it once, in first-seen order,
3936        // even when another denominator's section sits between them.
3937        let mut interleaved =
3938            fixture(&[ViewSpec::Languages, ViewSpec::Documents, ViewSpec::Families]);
3939        for (section, metric) in interleaved.sections.iter_mut().zip([
3940            ShareMetric::CodeLines,
3941            ShareMetric::DocumentWords,
3942            ShareMetric::CodeLines,
3943        ]) {
3944            let Section::Metrics { summary, .. } = section else {
3945                panic!("languages, documents, and families should be metric sections");
3946            };
3947            summary.share_metric = metric;
3948        }
3949        assert_eq!(
3950            shares(&interleaved),
3951            ["note: percentages are shares of code lines (LANGUAGES, FAMILIES), document words \
3952                 (DOCUMENTS)"],
3953            "a shared denominator is named once"
3954        );
3955    }
3956
3957    #[test]
3958    fn share_floor_omissions_name_their_section_beside_other_views() {
3959        let mut report = fixture(&[ViewSpec::Types, ViewSpec::Families]);
3960        for section in &mut report.sections {
3961            let Section::Metrics { summary, .. } = section else {
3962                panic!("types and families should be metric sections");
3963            };
3964            summary.share_omitted = 2;
3965        }
3966        let limits = report_notes(&report)
3967            .into_iter()
3968            .filter(|note| note.starts_with("note: display limits:"))
3969            .collect::<Vec<_>>();
3970        assert_eq!(
3971            limits,
3972            ["note: display limits: 2 rows below min share in TYPES, 2 rows below min share in \
3973              FAMILIES"],
3974            "equal counts in different views stay distinct"
3975        );
3976    }
3977
3978    #[test]
3979    fn json_output_is_well_formed_for_every_view() {
3980        for view in [
3981            ViewSpec::Tree,
3982            ViewSpec::Extensions,
3983            ViewSpec::Types,
3984            ViewSpec::Families,
3985            ViewSpec::Languages,
3986            ViewSpec::Documents,
3987            ViewSpec::Files,
3988            ViewSpec::Summary,
3989        ] {
3990            let json = render(&fixture(&[view]), Format::Json, false);
3991            assert!(is_valid_json(&json), "unbalanced JSON for {view:?}:\n{json}");
3992        }
3993        let all = render(
3994            &fixture(&[
3995                ViewSpec::Tree,
3996                ViewSpec::Extensions,
3997                ViewSpec::Types,
3998                ViewSpec::Files,
3999                ViewSpec::Summary,
4000            ]),
4001            Format::Json,
4002            false,
4003        );
4004        assert!(is_valid_json(&all), "unbalanced JSON for a multi-view report:\n{all}");
4005    }
4006
4007    #[test]
4008    fn streaming_machine_writers_match_string_rendering() {
4009        struct Fails;
4010        impl std::io::Write for Fails {
4011            fn write(&mut self, _buffer: &[u8]) -> std::io::Result<usize> {
4012                Err(std::io::Error::other("closed"))
4013            }
4014
4015            fn flush(&mut self) -> std::io::Result<()> {
4016                Ok(())
4017            }
4018        }
4019
4020        let report = fixture(&[
4021            ViewSpec::Tree,
4022            ViewSpec::Extensions,
4023            ViewSpec::Types,
4024            ViewSpec::Files,
4025            ViewSpec::Summary,
4026        ]);
4027        for format in [Format::Json, Format::Jsonl, Format::Yaml] {
4028            let expected = render(&report, format, false);
4029            let mut streamed = Vec::new();
4030            write(&report, format, false, &mut streamed).expect("stream report");
4031            assert_eq!(streamed, expected.as_bytes(), "{format:?} bytes differ");
4032        }
4033
4034        let error = write(&report, Format::Json, false, &mut Fails).expect_err("writer fails");
4035        assert_eq!(error.kind(), std::io::ErrorKind::Other);
4036    }
4037
4038    #[test]
4039    fn streaming_tree_walk_handles_many_siblings_without_collecting_output() {
4040        #[derive(Default)]
4041        struct Count(u64);
4042        impl std::io::Write for Count {
4043            fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
4044                self.0 = self.0.saturating_add(buffer.len() as u64);
4045                Ok(buffer.len())
4046            }
4047
4048            fn flush(&mut self) -> std::io::Result<()> {
4049                Ok(())
4050            }
4051        }
4052
4053        let mut report = fixture(&[ViewSpec::Tree]);
4054        let Section::Tree { root: Some(root), .. } = &mut report.sections[0] else {
4055            panic!("tree fixture must contain a tree");
4056        };
4057        let template = root.children[0].clone();
4058        root.children = (0..10_000)
4059            .map(|index| {
4060                let mut child = template.clone();
4061                child.name = format!("child-{index}");
4062                child.path = PathBuf::from(&child.name);
4063                child
4064            })
4065            .collect();
4066
4067        let mut output = Count::default();
4068        write(&report, Format::Json, false, &mut output).expect("stream wide report");
4069        assert!(output.0 > 1_000_000, "wide fixture must exercise substantial output");
4070    }
4071
4072    #[test]
4073    fn nested_json_separates_siblings_without_a_trailing_comma() {
4074        // The original fixture had no directory with two children, so a balanced-but-
4075        // invalid `[{a}{b},]` passed the structural check. Sibling separators need a
4076        // case that actually has siblings, at more than one level.
4077        let mut index = Index::new_with_scope("/root", ScanScope::default());
4078        index
4079            .apply(&Observation::new(vec![
4080                Op::Upsert {
4081                    path: PathBuf::from("a"),
4082                    kind: EntryKind::Dir,
4083                    attrs: Attrs::default(),
4084                },
4085                Op::Upsert {
4086                    path: PathBuf::from("b"),
4087                    kind: EntryKind::Dir,
4088                    attrs: Attrs::default(),
4089                },
4090                Op::Upsert {
4091                    path: PathBuf::from("c"),
4092                    kind: EntryKind::Dir,
4093                    attrs: Attrs::default(),
4094                },
4095                Op::Upsert {
4096                    path: PathBuf::from("a/inner"),
4097                    kind: EntryKind::Dir,
4098                    attrs: Attrs::default(),
4099                },
4100                Op::Upsert {
4101                    path: PathBuf::from("a/other"),
4102                    kind: EntryKind::Dir,
4103                    attrs: Attrs::default(),
4104                },
4105            ]))
4106            .expect("apply");
4107        let report = report(
4108            &index,
4109            &crate::test_support::read_of(
4110                &index,
4111                Query {
4112                    selection: Selection {
4113                        depth: Some(Bound::All),
4114                        min_share: Some(crate::query::ShareThreshold::parse("0%").expect("share")),
4115                        ..Selection::default()
4116                    },
4117                    views: vec![ViewSpec::Tree],
4118                    ..Query::default()
4119                },
4120            ),
4121            &Provenance {
4122                scan_started_at: None,
4123                generated_at: UNIX_EPOCH,
4124                source: ReportSource::ColdScan,
4125                complete: true,
4126                errors: Vec::new(),
4127            },
4128        )
4129        .expect("report");
4130
4131        let json = render(&report, Format::Json, false);
4132        assert!(is_valid_json(&json), "{json}");
4133        assert!(!json.contains("}{"), "siblings must be separated:\n{json}");
4134        assert!(!json.contains(",]"), "no trailing comma before a close:\n{json}");
4135        assert!(!json.contains("[,"), "no leading comma after an open:\n{json}");
4136        // Three top-level siblings and two nested ones must all be present.
4137        for name in ["\"a\"", "\"b\"", "\"c\"", "\"inner\"", "\"other\""] {
4138            assert!(json.contains(name), "missing {name} in:\n{json}");
4139        }
4140    }
4141
4142    #[test]
4143    fn jsonl_emits_one_document_per_line() {
4144        let rendered =
4145            render(&fixture(&[ViewSpec::Extensions, ViewSpec::Summary]), Format::Jsonl, false);
4146        let lines: Vec<&str> = rendered.lines().collect();
4147        assert_eq!(lines.len(), 3, "one envelope plus one line per section");
4148        for line in &lines {
4149            assert!(is_valid_json(line), "line is not a JSON document: {line}");
4150        }
4151        assert!(lines[0].contains("\"schema\""), "the envelope carries provenance");
4152    }
4153
4154    #[test]
4155    fn machine_output_carries_the_schema_and_provenance() {
4156        let json = render(&fixture(&[ViewSpec::Summary]), Format::Json, false);
4157        assert!(json.contains("\"schema\": \"fdu.report/10\""));
4158        assert!(json.contains("\"request\": {"));
4159        assert!(json.contains("\"status\": {"));
4160        assert!(json.contains("\"provenance\": {"));
4161        assert!(json.contains("\"source\": \"cold_scan\""));
4162        assert!(json.contains("\"complete\": true"));
4163        // Timestamps render in the same grammar the CLI accepts back as a watermark.
4164        assert!(json.contains("\"scan_started_at\": \"2026-08-10T18:22:31.000000000Z\""), "{json}");
4165        assert!(json.contains("\"generated_at\": \"2026-08-10T18:22:32.000000000Z\""), "{json}");
4166    }
4167
4168    #[test]
4169    fn the_schema_constant_is_the_versioning_promise() {
4170        // Fails loudly when the schema string moves, so a field rename cannot ship
4171        // without a deliberate version bump and a golden update.
4172        assert_eq!(REPORT_SCHEMA, "fdu.report/10");
4173        assert_eq!(CONTENT_REPORT_SCHEMA, REPORT_SCHEMA);
4174    }
4175
4176    /// Every format says whether ignore rules were read and which files were refused, and
4177    /// text names the directories and the knob as the requesting surface spells it.
4178    #[test]
4179    fn every_format_states_the_ignore_rules_a_report_could_apply() {
4180        let unobserved = crate::test_support::not_observing_controls();
4181        let mut blind = Index::new_with_scope("/root", unobserved);
4182        blind
4183            .apply(&Observation::new(vec![Op::Upsert {
4184                path: PathBuf::from("a.txt"),
4185                kind: EntryKind::File,
4186                attrs: attrs(1, 1),
4187            }]))
4188            .expect("apply");
4189        let provenance = Provenance {
4190            scan_started_at: None,
4191            generated_at: UNIX_EPOCH,
4192            source: ReportSource::ColdScan,
4193            complete: true,
4194            errors: Vec::new(),
4195        };
4196        let query = Query { views: vec![ViewSpec::Summary], ..Query::default() };
4197        let blind_report =
4198            report(&blind, &crate::test_support::read_of(&blind, query.clone()), &provenance)
4199                .expect("report");
4200        assert!(render(&blind_report, Format::Json, false).contains("\"ignore_rules\": null"));
4201        assert!(render(&blind_report, Format::Yaml, false).contains("\nignore_rules: null\n"));
4202        assert!(blind_report.notes.is_empty());
4203
4204        let mut observed =
4205            Index::new_with_scope("/root", crate::test_support::observing_controls());
4206        let mut long_line = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
4207        long_line.push(b'\n');
4208        observed
4209            .apply(&Observation::new(vec![
4210                Op::Upsert {
4211                    path: PathBuf::from("vendor"),
4212                    kind: EntryKind::Dir,
4213                    attrs: attrs(0, 1),
4214                },
4215                Op::ControlUpsert {
4216                    path: PathBuf::from(".gitignore"),
4217                    source: b"*.log\n".to_vec(),
4218                },
4219                Op::ControlUpsert { path: PathBuf::from("vendor/.gitignore"), source: long_line },
4220            ]))
4221            .expect("apply");
4222        // The platform spells the refused path, so Windows writes a backslash.
4223        let refused = Path::new("vendor").join(".gitignore");
4224        let refused = refused.to_string_lossy();
4225        let json = render(
4226            &report(
4227                &observed,
4228                &crate::test_support::read_of(&observed, query.clone()),
4229                &provenance,
4230            )
4231            .expect("report"),
4232            Format::Json,
4233            false,
4234        );
4235        let expected = format!(
4236            "\"ignore_rules\": {{\"limits\": {{\"budget\": 4194304, \"line_limit\": 16384}}, \
4237             \"applied\": 1, \"rules\": 1, \"refused\": 1, \"refusals\": [{{\"path\": {}, \"reason\": \
4238             \"line_limit\"}}]}}",
4239            quote(&refused)
4240        );
4241        assert!(compact_json(&json).contains(&compact_json(&expected)), "{json}");
4242        assert!(json.contains("\"complete\": true"), "a refusal is not an operational partial");
4243        let yaml = render(
4244            &report(
4245                &observed,
4246                &crate::test_support::read_of(&observed, query.clone()),
4247                &provenance,
4248            )
4249            .expect("report"),
4250            Format::Yaml,
4251            false,
4252        );
4253        let expected = format!(
4254            "ignore_rules:\n  limits: {{budget: 4194304, line_limit: 16384}}\n  applied: 1\n  rules: 1\n  \
4255             refused: 1\n  refusals:\n    -\n      path: {}\n      reason: line_limit\n",
4256            yaml_scalar(&refused)
4257        );
4258        assert!(yaml.contains(&expected), "{yaml}");
4259
4260        let flags = Query { axes: &crate::query::AxisNames::FLAGS, ..query.clone() };
4261        let candidate =
4262            report(&observed, &crate::test_support::read_of(&observed, flags), &provenance)
4263                .expect("report");
4264        let text = render(&candidate, Format::Text, false);
4265        assert!(
4266            !text.contains("note:") && !text.contains("tip:"),
4267            "stdout holds only formatted data"
4268        );
4269        let lines = diagnostics(&candidate);
4270        assert!(
4271            lines.iter().any(|line| line.starts_with("note: ignore classification incomplete:")
4272                && line.contains("vendor"))
4273        );
4274        assert!(lines.last().expect("remedy").contains("--gitignore-line-limit"));
4275        let fields =
4276            report(&observed, &crate::test_support::read_of(&observed, query), &provenance)
4277                .expect("report");
4278        assert!(report_tips(&fields).iter().any(|tip| tip.contains("control_line_limit")));
4279        assert!(!diagnostics(&fields).iter().any(|line| line.contains("--gitignore-line-limit")));
4280    }
4281
4282    /// Every row that carries an ignored share says so in every format: text appends it
4283    /// only when something is ignored and the selection is not ignored entries alone, and
4284    /// machine formats write a zero share when nothing is and `null` when no rule was read.
4285    #[test]
4286    fn every_format_carries_each_rows_ignored_share() {
4287        let build = |scope: ScanScope| {
4288            let mut index = Index::new_with_scope("/root", scope);
4289            let mut ops = vec![
4290                Op::Upsert {
4291                    path: PathBuf::from("dist"),
4292                    kind: EntryKind::Dir,
4293                    attrs: Attrs::default(),
4294                },
4295                Op::Upsert {
4296                    path: PathBuf::from("dist/a.gz"),
4297                    kind: EntryKind::File,
4298                    attrs: attrs(128, 10),
4299                },
4300                Op::Upsert {
4301                    path: PathBuf::from("src"),
4302                    kind: EntryKind::Dir,
4303                    attrs: Attrs::default(),
4304                },
4305                Op::Upsert {
4306                    path: PathBuf::from("src/b.rs"),
4307                    kind: EntryKind::File,
4308                    attrs: attrs(36, 20),
4309                },
4310            ];
4311            if scope.observes_controls() {
4312                ops.insert(
4313                    0,
4314                    Op::ControlUpsert {
4315                        path: PathBuf::from(".gitignore"),
4316                        source: b"dist/\n".to_vec(),
4317                    },
4318                );
4319            }
4320            index.apply(&Observation::new(ops)).expect("apply");
4321            index
4322        };
4323        let provenance = Provenance {
4324            scan_started_at: None,
4325            generated_at: UNIX_EPOCH,
4326            source: ReportSource::ColdScan,
4327            complete: true,
4328            errors: Vec::new(),
4329        };
4330        let views = vec![ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions, ViewSpec::Files];
4331        let query = |ignored| Query {
4332            views: views.clone(),
4333            selection: Selection {
4334                ignored,
4335                size: SizeMetric::Apparent,
4336                limit: Some(Bound::All),
4337                ..Selection::default()
4338            },
4339            ..Query::default()
4340        };
4341
4342        let observed = build(crate::test_support::observing_controls());
4343        let text = render(
4344            &report(
4345                &observed,
4346                &crate::test_support::read_of(&observed, query(IgnoredEntries::Include)),
4347                &provenance,
4348            )
4349            .expect("report"),
4350            Format::Text,
4351            false,
4352        );
4353        assert_eq!(
4354            text,
4355            concat!(
4356                "SUMMARY\n",
4357                "     164 B  2 files, 2 directories (128 B gitignored)\n",
4358                "\n",
4359                "TREE\n",
4360                "██████████   100%       164 B  . 2 files (128 B gitignored)\n",
4361                "████████░░    78%       128 B    dist/ 1 file (128 B gitignored)\n",
4362                "████████░░    78%       128 B      a.gz (128 B gitignored)\n",
4363                "██░░░░░░░░    22%        36 B    src/ 1 file\n",
4364                "██░░░░░░░░    22%        36 B      b.rs\n",
4365                "\n",
4366                "EXTENSIONS\n",
4367                "     128 B  .gz          1 file (128 B gitignored)\n",
4368                "      36 B  .rs          1 file\n",
4369                "\n",
4370                "FILES\n",
4371                "dist\n",
4372                "dist/a.gz\n",
4373                "src\n",
4374                "src/b.rs\n",
4375            )
4376            .replace("dist/a.gz", &format!("dist{}a.gz", std::path::MAIN_SEPARATOR))
4377            .replace("src/b.rs", &format!("src{}b.rs", std::path::MAIN_SEPARATOR))
4378        );
4379        // A share of ignored directories alone holds no bytes, so text says nothing of it.
4380        let dirs_only = IgnoredTally { files: 0, dirs: 1, bytes: 0, allocated: 0 };
4381        assert_eq!(
4382            ignored_suffix(Some(dirs_only), SizeMetric::Apparent, IgnoredEntries::Include, false),
4383            ""
4384        );
4385        let large_ignored = IgnoredTally { files: 1, dirs: 0, bytes: 1 << 30, allocated: 1 << 30 };
4386        let suffix = ignored_suffix(
4387            Some(large_ignored),
4388            SizeMetric::Apparent,
4389            IgnoredEntries::Include,
4390            true,
4391        );
4392        assert!(suffix.contains(&paint("1.0 GiB", STYLE_DETAIL.bold(), true)), "{suffix:?}");
4393        assert_eq!(strip_ansi(&suffix), " (1.0 GiB gitignored)");
4394        let only = render(
4395            &report(
4396                &observed,
4397                &crate::test_support::read_of(&observed, query(IgnoredEntries::Only)),
4398                &provenance,
4399            )
4400            .expect("report"),
4401            Format::Text,
4402            false,
4403        );
4404        assert!(only.contains("     128 B  1 file, 1 directory\n"), "{only}");
4405        assert!(!only.contains("ignored"), "every row is ignored, so none repeats it: {only}");
4406
4407        let json = render(
4408            &report(
4409                &observed,
4410                &crate::test_support::read_of(&observed, query(IgnoredEntries::Include)),
4411                &provenance,
4412            )
4413            .expect("report"),
4414            Format::Json,
4415            false,
4416        );
4417        assert!(is_valid_json(&json), "{json}");
4418        let compact = compact_json(&json);
4419        for expected in [
4420            "\"summary\": {\"files\": 2, \"dirs\": 2, \"bytes\": 164, \"allocated\": 1024, \
4421             \"ignored\": {\"files\": 1, \"dirs\": 1, \"bytes\": 128, \"allocated\": 512}, ",
4422            "\"name\": \"src\", \"path\": \"src\", \"kind\": \"dir\", \"entry_ignored\": false, \"bytes\": 36, \
4423             \"allocated\": 512, \"files\": 1, \"dirs\": 0, \"ignored\": {\"files\": 0, \
4424             \"dirs\": 0, \"bytes\": 0, \"allocated\": 0}, ",
4425            "{\"extension\": \".gz\", \"files\": 1, \"bytes\": 128, \"allocated\": 512, \
4426             \"ignored\": {\"files\": 1, \"bytes\": 128, \"allocated\": 512}}",
4427            "\"kind\": \"dir\", \"bytes\": 128, \"allocated\": 512, \"mtime_ns\": 10, \"files\": 1, \"dirs\": 0, \"complete\": true, \"age_ns\": -10, \"ignored\": true, \"sort_value\": null, \"classification\": null}",
4428        ] {
4429            assert!(compact.contains(&compact_json(expected)), "missing {expected}\nin {json}");
4430        }
4431        let yaml = render(
4432            &report(
4433                &observed,
4434                &crate::test_support::read_of(&observed, query(IgnoredEntries::Include)),
4435                &provenance,
4436            )
4437            .expect("report"),
4438            Format::Yaml,
4439            false,
4440        );
4441        assert!(
4442            yaml.contains(
4443                "      allocated: 1024\n      ignored: {files: 1, dirs: 1, bytes: 128, \
4444                 allocated: 512}\n      newest_mtime_ns: 20\n"
4445            ),
4446            "{yaml}"
4447        );
4448        assert!(yaml.contains("        ignored: true\n"), "{yaml}");
4449
4450        let blind = build(crate::test_support::not_observing_controls());
4451        let blind_report = report(
4452            &blind,
4453            &crate::test_support::read_of(&blind, query(IgnoredEntries::Include)),
4454            &provenance,
4455        )
4456        .expect("report");
4457        assert!(!render(&blind_report, Format::Text, false).contains("ignored"));
4458        let json = render(&blind_report, Format::Json, false);
4459        assert!(!json.contains("\"ignored\": {"), "never a zero share for an unread rule: {json}");
4460        assert!(json.contains("\"ignored\": null"), "{json}");
4461        assert!(render(&blind_report, Format::Yaml, false).contains("ignored: null\n"));
4462    }
4463
4464    #[test]
4465    fn every_report_uses_one_schema_and_states_nullable_analysis() {
4466        let metadata = render(&fixture(&[ViewSpec::Tree]), Format::Json, false);
4467        assert!(metadata.contains("\"schema\": \"fdu.report/10\""));
4468        assert!(metadata.contains("\"analysis\": null"));
4469
4470        let metrics = render(&fixture(&[ViewSpec::Types]), Format::Json, false);
4471        assert!(metrics.contains("\"schema\": \"fdu.report/10\""));
4472        assert!(metrics.contains("\"analysis\": null"));
4473        assert!(metrics.contains("\"share\": {\"numerator\":"));
4474    }
4475
4476    /// The change stream carries the same promise the report does.
4477    ///
4478    /// A constant assertion alone would not: it pins the version string while leaving the
4479    /// record's shape free to change underneath it, which is the failure the promise
4480    /// exists to prevent. This pins the whole record, so adding, renaming, or reordering
4481    /// a field fails here and forces a deliberate version bump.
4482    ///
4483    /// `ignored` was added to `fdu.stream/2` before the first release, so no consumer has
4484    /// ever read the earlier draft shape it extends.
4485    #[cfg(feature = "watch")]
4486    #[test]
4487    fn a_stream_record_is_pinned_field_by_field() {
4488        use crate::{Change, ChangeKind};
4489
4490        assert_eq!(STREAM_SCHEMA, "fdu.stream/2");
4491
4492        let upsert = Change {
4493            path: ["src", "main.rs"].iter().collect(),
4494            kind: ChangeKind::Upsert,
4495            entry_kind: Some(EntryKind::File),
4496            bytes: Some(2_048),
4497            allocated: Some(4_096),
4498            mtime_ns: Some(1_700_000_000_000_000_000),
4499            ignored: Some(false),
4500            clock: 7,
4501        };
4502        // Path separators differ by platform, so the expectation is built the same way
4503        // the renderer builds it rather than hardcoding a slash.
4504        let path = upsert.path.to_string_lossy().replace('\\', "\\\\");
4505        assert_eq!(
4506            render_change(&upsert, Format::Json),
4507            format!(
4508                "{{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"upsert\", \
4509                 \"path\": \"{path}\", \"clock\": 7, \"kind\": \"file\", \"bytes\": 2048, \
4510                 \"allocated\": 4096, \"mtime_ns\": 1700000000000000000, \"ignored\": false}}"
4511            )
4512        );
4513
4514        // A run that read no ignore rules classifies nothing, and the field is absent
4515        // rather than false: the same distinction every report row draws.
4516        let unclassified = Change { ignored: None, ..upsert.clone() };
4517        assert_eq!(
4518            render_change(&unclassified, Format::Json),
4519            format!(
4520                "{{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"upsert\", \
4521                 \"path\": \"{path}\", \"clock\": 7, \"kind\": \"file\", \"bytes\": 2048, \
4522                 \"allocated\": 4096, \"mtime_ns\": 1700000000000000000}}"
4523            )
4524        );
4525
4526        // A removal has no metadata to report, and the optional fields must be absent
4527        // rather than null: a consumer distinguishes "gone" from "unknown" by their
4528        // absence.
4529        let removed = Change {
4530            path: PathBuf::from("gone.txt"),
4531            kind: ChangeKind::Remove,
4532            entry_kind: None,
4533            bytes: None,
4534            allocated: None,
4535            mtime_ns: None,
4536            ignored: None,
4537            clock: 8,
4538        };
4539        assert_eq!(
4540            render_change(&removed, Format::Json),
4541            "{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"remove\", \
4542             \"path\": \"gone.txt\", \"clock\": 8}"
4543        );
4544
4545        // A removal an ignore-rule edit caused is the one that carries a classification:
4546        // the entry is still on disk, and the new bit is why it left the selection.
4547        let reclassified =
4548            Change { path: PathBuf::from("debug.log"), ignored: Some(true), ..removed.clone() };
4549        assert_eq!(
4550            render_change(&reclassified, Format::Json),
4551            "{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"remove\", \
4552             \"path\": \"debug.log\", \"clock\": 8, \"ignored\": true}"
4553        );
4554
4555        // An invalidation says the consumer's view may have gaps. It is the one record
4556        // that must never be dropped, so its shape is pinned too.
4557        let invalidated = Change {
4558            path: PathBuf::from("subtree"),
4559            kind: ChangeKind::Invalidate,
4560            entry_kind: None,
4561            bytes: None,
4562            allocated: None,
4563            mtime_ns: None,
4564            ignored: None,
4565            clock: 9,
4566        };
4567        assert_eq!(
4568            render_change(&invalidated, Format::Json),
4569            "{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"invalidate\", \
4570             \"path\": \"subtree\", \"clock\": 9}"
4571        );
4572
4573        // Text is the greppable form: path first, operation second, tab-separated.
4574        assert_eq!(render_change(&removed, Format::Text), "gone.txt\tremove");
4575    }
4576
4577    #[test]
4578    fn a_files_view_prints_one_path_per_line_and_nothing_else() {
4579        // The property that makes `fdu --view files | xargs` work. It is why the view
4580        // header is conditional: a lone files view is a path listing, not a table that
4581        // needs labelling, so nothing is prepended to it.
4582        let text = render(&fixture(&[ViewSpec::Files]), Format::Text, false);
4583        for line in text.lines() {
4584            assert!(!line.contains(' '), "text files output must be bare paths, got {line:?}");
4585        }
4586        let expected: PathBuf = ["src", "main.rs"].iter().collect();
4587        let expected = expected.display().to_string();
4588        assert!(text.lines().any(|line| line == expected), "{text}");
4589    }
4590
4591    #[test]
4592    fn several_views_are_labelled_and_a_lone_view_is_left_bare() {
4593        // Concatenated blocks of similar-looking rows were the problem: a reader had to
4594        // recover which view produced which table from the order they were requested in.
4595        let text = render(
4596            &fixture(&[ViewSpec::Tree, ViewSpec::Types, ViewSpec::Summary]),
4597            Format::Text,
4598            false,
4599        );
4600        let headers: Vec<&str> = text.lines().filter(|line| is_view_header_line(line)).collect();
4601        assert_eq!(headers, ["TREE", "TYPES", "SUMMARY"], "{text}");
4602
4603        // Each header sits directly above the rows it labels, and one blank line
4604        // separates the blocks.
4605        let lines: Vec<&str> = text.lines().collect();
4606        for (index, line) in lines.iter().enumerate() {
4607            if headers.contains(line) {
4608                assert!(
4609                    lines.get(index + 1).is_some_and(|next| !next.is_empty()),
4610                    "header {line} must sit directly above its rows:\n{text}"
4611                );
4612                if index > 0 {
4613                    assert!(
4614                        lines[index - 1].is_empty(),
4615                        "a blank line must precede header {line}:\n{text}"
4616                    );
4617                }
4618            }
4619        }
4620
4621        // The same views alone keep the pre-header layout exactly.
4622        for view in [ViewSpec::Tree, ViewSpec::Types, ViewSpec::Summary] {
4623            let lone = render(&fixture(&[view]), Format::Text, false);
4624            assert!(
4625                !lone.lines().any(is_view_header_line),
4626                "{view:?} alone must not be labelled:\n{lone}"
4627            );
4628        }
4629    }
4630
4631    #[test]
4632    fn view_headers_are_colorized_only_when_color_is_on() {
4633        let views = [ViewSpec::Tree, ViewSpec::Summary];
4634        let plain = render(&fixture(&views), Format::Text, false);
4635        assert!(plain.starts_with("TREE\n"), "{plain}");
4636        assert!(!plain.contains('\u{1b}'), "uncolored text carries no escapes: {plain:?}");
4637
4638        let colored = render(&fixture(&views), Format::Text, true);
4639        assert!(colored.contains(&paint("TREE", STYLE_HEADING, true)), "{colored:?}");
4640        assert!(colored.contains(&paint("SUMMARY", STYLE_HEADING, true)), "{colored:?}");
4641    }
4642
4643    #[test]
4644    fn human_rows_style_only_their_semantic_spans() {
4645        let ignored = IgnoredTally { files: 1, dirs: 0, bytes: 43, allocated: 43 };
4646        let mut tree = fixture(&[ViewSpec::Tree]);
4647        let Section::Tree { root: Some(root), .. } = &mut tree.sections[0] else { panic!("tree") };
4648        root.name = "a(b)\n界".into();
4649        root.files = 3_508;
4650        root.ignored = Some(ignored);
4651        let plain = render(&tree, Format::Text, false);
4652        let colored = render(&tree, Format::Text, true);
4653        assert!(
4654            colored.contains(&format!(
4655                "{} 3,508 files {}",
4656                human_name("a(b)\n界", EntryKind::Dir, None, true),
4657                detail("(43 B gitignored)", true)
4658            )),
4659            "{colored:?}"
4660        );
4661        assert_eq!(strip_ansi(&colored).replace('·', "░").replace('▒', "█"), plain);
4662        assert!(!colored.contains("a(b)\n界"));
4663
4664        let mut summary = fixture(&[ViewSpec::Summary]);
4665        let Section::Summary(row) = &mut summary.sections[0] else { panic!("summary") };
4666        row.files = 3_508;
4667        row.ignored = Some(ignored);
4668        let colored = render(&summary, Format::Text, true);
4669        assert!(
4670            colored.contains(&format!(
4671                "3,508 files, 1 directory {}",
4672                detail("(43 B gitignored)", true)
4673            )),
4674            "{colored:?}"
4675        );
4676        assert_eq!(strip_ansi(&colored), render(&summary, Format::Text, false));
4677
4678        let mut types = fixture(&[ViewSpec::Extensions]);
4679        let Section::Extensions { rows, .. } = &mut types.sections[0] else { panic!("extensions") };
4680        rows[0].extension = ".(txt)".into();
4681        rows[0].files = 3_508;
4682        rows[0].ignored = Some(ignored);
4683        let colored = render(&types, Format::Text, true);
4684        assert!(colored.contains(&paint(".(txt)", STYLE_CATEGORY, true)), "{colored:?}");
4685        assert!(
4686            colored.contains(&format!("3,508 files {}", detail("(43 B gitignored)", true))),
4687            "{colored:?}"
4688        );
4689        assert_eq!(strip_ansi(&colored), render(&types, Format::Text, false));
4690    }
4691
4692    /// Where a non-language grouped row's continuation lines start: past the size, share,
4693    /// and floored label cells and the space before the file count, then two more.
4694    const TEXT_METRIC_INDENT: usize =
4695        TEXT_SIZE_WIDTH + 2 + TEXT_SHARE_WIDTH + 2 + TEXT_METRIC_LABEL_WIDTH + 1 + 2;
4696
4697    #[test]
4698    fn metric_breakdowns_and_ranked_paths_keep_span_boundaries() {
4699        let mut metrics = fixture(&[ViewSpec::Types]);
4700        let Section::Metrics { summary, .. } = &mut metrics.sections[0] else { panic!("metrics") };
4701        let row = &mut summary.rows[0];
4702        row.files = 1_234;
4703        row.metrics.physical_lines = Some(477_298);
4704        row.metrics.nonblank_lines = Some(439_949);
4705        row.metrics.blank_lines = Some(37_349);
4706        let colored = render(&metrics, Format::Text, true);
4707        assert!(
4708            colored.contains(&format!(
4709                "1,234 files\n{}477,298 lines {}\n",
4710                " ".repeat(TEXT_METRIC_INDENT),
4711                detail("(439,949 nonblank, 37,349 blank)", true)
4712            )),
4713            "{colored:?}"
4714        );
4715        assert_eq!(strip_ansi(&colored), render(&metrics, Format::Text, false));
4716
4717        let mut largest = fixture(&[ViewSpec::Largest]);
4718        let Section::Files { rows, .. } = &mut largest.sections[0] else { panic!("files") };
4719        rows[0].path = PathBuf::from("a(b)\n界.rs");
4720        let colored = render(&largest, Format::Text, true);
4721        assert!(colored.contains(&paint("a(b)\\n界.rs", STYLE_NAME, true)), "{colored:?}");
4722        assert_eq!(strip_ansi(&colored), render(&largest, Format::Text, false));
4723        for format in [Format::Json, Format::Jsonl, Format::Yaml] {
4724            assert!(!render(&largest, format, true).contains('\u{1b}'));
4725        }
4726    }
4727
4728    /// A grouped row's first line is its tabulated cells and file count; each further
4729    /// measure is a line of its own, two columns right of the file count, with the size,
4730    /// share, and label cells left blank. The widest row then no longer sets the report's
4731    /// width, which on the Linux kernel's DOCUMENTS was 154 columns (fdu-r7lw).
4732    #[test]
4733    fn grouped_rows_stack_each_further_measure_under_the_file_count() {
4734        for view in [ViewSpec::Types, ViewSpec::Families, ViewSpec::Languages, ViewSpec::Documents]
4735        {
4736            let mut metrics = fixture(&[view]);
4737            let Section::Metrics { summary, .. } = &mut metrics.sections[0] else {
4738                panic!("{view:?} should be a metric section")
4739            };
4740            let row = &mut summary.rows[0];
4741            row.files = 4_064;
4742            row.metrics.physical_lines = Some(834_425);
4743            row.metrics.nonblank_lines = Some(633_499);
4744            row.metrics.blank_lines = Some(200_926);
4745            row.metrics.document_words = Some(4_390_955);
4746            row.generated_files = 2;
4747            row.vendored_files = 3;
4748            row.documentation_files = 4_063;
4749            row.code_coverage = None;
4750            row.words_coverage = None;
4751            row.lines_coverage = std::collections::BTreeMap::from([
4752                (CoverageReason::Analyzed, 3_997),
4753                (CoverageReason::Binary, 60),
4754                (CoverageReason::Unsupported, 7),
4755            ]);
4756            let plain = render(&metrics, Format::Text, false);
4757            let lines = plain.lines().collect::<Vec<_>>();
4758            // In columns, not bytes: an unmeasured share is the three-byte `—`.
4759            let at = lines[0].find("4,064 files").expect("the file count ends the first line");
4760            let column = display_width(&lines[0][..at]);
4761            assert!(lines[0].ends_with("4,064 files"), "{plain}");
4762            let indent = " ".repeat(column + 2);
4763            let stacked = [
4764                "834,425 lines (633,499 nonblank, 200,926 blank)",
4765                "4,390,955 words (17,563.8 pages)",
4766                "2 generated",
4767                "3 vendored",
4768                "60 binary",
4769                "7 unsupported",
4770            ]
4771            .map(|measure| format!("{indent}{measure}"));
4772            assert_eq!(lines[1..=stacked.len()], stacked, "{view:?}:\n{plain}");
4773            assert!(plain.lines().all(|line| !line.ends_with(' ')), "{plain:?}");
4774            // The documentation-file count is machine output only.
4775            assert!(!plain.contains("documentation"), "{view:?}:\n{plain}");
4776
4777            let colored = render(&metrics, Format::Text, true);
4778            assert_eq!(strip_ansi(&colored), plain, "color must not move a continuation");
4779            for (measure, breakdown) in [
4780                ("834,425 lines", "(633,499 nonblank, 200,926 blank)"),
4781                ("4,390,955 words", "(17,563.8 pages)"),
4782            ] {
4783                let expected = format!("\n{indent}{measure} {}\n", detail(breakdown, true));
4784                assert!(colored.contains(&expected), "{view:?}: {colored:?}");
4785            }
4786        }
4787
4788        // The non-language views share a label floor, so their continuations sit at one
4789        // column across sections; a row with nothing shown past its file count, its
4790        // documentation-file count included, stays one line.
4791        let mut types = fixture(&[ViewSpec::Types]);
4792        let Section::Metrics { summary, .. } = &mut types.sections[0] else { panic!("types") };
4793        assert_eq!(summary.rows.len(), 2, "a measured row and a bare one");
4794        summary.rows[0].files = 12;
4795        summary.rows[0].generated_files = 12;
4796        summary.rows[1].files = 1;
4797        summary.rows[1].documentation_files = 1;
4798        let plain = render(&types, Format::Text, false);
4799        let lines = plain.lines().collect::<Vec<_>>();
4800        assert_eq!(lines.len(), 3, "{plain}");
4801        assert_eq!(lines[1], format!("{}12 generated", " ".repeat(TEXT_METRIC_INDENT)));
4802        assert!(lines[2].ends_with(" 1 file"), "{plain}");
4803        assert_eq!(lines[2].find("1 file"), lines[0].find("12 files"), "{plain}");
4804        let json = render(&types, Format::Json, false);
4805        assert!(json.contains("\"documentation\": 1}"), "machine output keeps it: {json}");
4806    }
4807
4808    #[test]
4809    fn code_population_details_keep_known_and_unknown_contributions_visible() {
4810        use crate::query::{CodeLanguageRow, MetricShare};
4811        let tally = |lines| CodeTally {
4812            source_files: 1,
4813            analyzed_files: 1,
4814            metrics: crate::content::CodeMetrics { code_lines: lines, ..Default::default() },
4815            ..Default::default()
4816        };
4817        let mut overview = CodeOverview {
4818            population: IgnoredEntries::Include,
4819            selected: CodeTally { source_files: 3, analyzed_files: 3, ..tally(110) },
4820            non_ignored: Some(tally(80)),
4821            ignored: Some(tally(20)),
4822            unknown: tally(10),
4823            unclassified_files: 0,
4824            analyzed_languages: 1,
4825            total_languages: 1,
4826            share_omitted: 0,
4827            languages: vec![CodeLanguageRow {
4828                language: "rust".into(),
4829                selected: CodeTally { source_files: 3, analyzed_files: 3, ..tally(110) },
4830                non_ignored: Some(tally(80)),
4831                ignored: Some(tally(20)),
4832                unknown: tally(10),
4833                share: MetricShare { numerator: 110, denominator: 110 },
4834            }],
4835            share_metric: ShareMetric::CodeLines,
4836        };
4837        let mut report = fixture(&[ViewSpec::Summary]);
4838        report.sections = vec![Section::Code(Box::new(overview.clone()))];
4839        let colored = render(&report, Format::Text, true);
4840        assert!(colored.contains(&paint("TOTAL   ", AnsiStyle::new().bold(), true)), "{colored:?}");
4841        let colored_total = colored.lines().find(|line| line.contains("TOTAL")).expect("total row");
4842        assert_eq!(colored_total.matches("\x1b[1m").count(), 6, "all primary TOTAL cells are bold");
4843        assert!(colored.contains(&detail("(20 gitignored, 10 unknown)", true)), "{colored:?}");
4844        assert!(!colored.contains("non-gitignored"), "the complement is never repeated");
4845        let plain = strip_ansi(&colored);
4846        assert!(
4847            plain.lines().next().expect("header").contains("Analyzed files  Language"),
4848            "{plain}"
4849        );
4850        let total = plain.lines().find(|line| line.contains("TOTAL")).expect("total row");
4851        assert_eq!(
4852            total.split_whitespace().take(6).collect::<Vec<_>>(),
4853            ["110", "100.0%", "0", "0", "3/3", "TOTAL"]
4854        );
4855        assert_eq!(
4856            plain.matches("       110").count(),
4857            2,
4858            "row and TOTAL retain the same measured total"
4859        );
4860        assert_eq!(strip_ansi(&colored), render(&report, Format::Text, false));
4861        for format in [Format::Json, Format::Jsonl, Format::Yaml] {
4862            assert!(!render(&report, format, true).contains('\u{1b}'));
4863        }
4864        overview.population = IgnoredEntries::Only;
4865        overview.non_ignored = None;
4866        overview.ignored = None;
4867        overview.unknown = CodeTally::default();
4868        overview.languages[0].non_ignored = None;
4869        overview.languages[0].ignored = None;
4870        overview.languages[0].unknown = CodeTally::default();
4871        report.sections = vec![Section::Code(Box::new(overview))];
4872        let text = render(&report, Format::Text, false);
4873        assert!(!text.contains("gitignored") && !text.contains(" unknown"), "{text}");
4874    }
4875
4876    #[test]
4877    fn code_table_keeps_global_total_when_rows_are_bounded_and_marks_unmeasured_values() {
4878        use crate::query::{CodeLanguageRow, MetricShare};
4879        let mut overview = CodeOverview {
4880            population: IgnoredEntries::Include,
4881            selected: CodeTally {
4882                source_files: 2,
4883                analyzed_files: 2,
4884                metrics: crate::content::CodeMetrics { code_lines: 12, ..Default::default() },
4885                ..Default::default()
4886            },
4887            non_ignored: None,
4888            ignored: None,
4889            unknown: CodeTally::default(),
4890            unclassified_files: 0,
4891            analyzed_languages: 1,
4892            total_languages: 1,
4893            share_omitted: 0,
4894            languages: Vec::new(),
4895            share_metric: ShareMetric::CodeLines,
4896        };
4897        let mut report = fixture(&[ViewSpec::Summary]);
4898        report.sections = vec![Section::Code(Box::new(overview.clone()))];
4899        let bounded = render(&report, Format::Text, false);
4900        let total = bounded.lines().find(|line| line.contains("TOTAL")).expect("total row");
4901        assert_eq!(
4902            total.split_whitespace().take(6).collect::<Vec<_>>(),
4903            ["12", "100.0%", "0", "0", "2/2", "TOTAL"]
4904        );
4905        assert!(!bounded.contains("Rust"), "{bounded}");
4906
4907        overview.selected.analyzed_files = 0;
4908        overview.selected.metrics = crate::content::CodeMetrics::default();
4909        overview.selected.coverage.insert(CoverageReason::Unsupported, 2);
4910        overview.analyzed_languages = 0;
4911        overview.non_ignored = Some(CodeTally::default());
4912        overview.ignored = Some(CodeTally::default());
4913        overview.languages = vec![CodeLanguageRow {
4914            language: "rust".into(),
4915            selected: CodeTally { source_files: 2, ..Default::default() },
4916            non_ignored: Some(CodeTally::default()),
4917            ignored: Some(CodeTally::default()),
4918            unknown: CodeTally::default(),
4919            share: MetricShare { numerator: 0, denominator: 0 },
4920        }];
4921        report.sections = vec![Section::Code(Box::new(overview))];
4922        let unmeasured = render(&report, Format::Text, false);
4923        let language = unmeasured.lines().find(|line| line.contains("Rust")).expect("language row");
4924        let total = unmeasured.lines().find(|line| line.contains("TOTAL")).expect("total row");
4925        assert_eq!(
4926            language.split_whitespace().take(6).collect::<Vec<_>>(),
4927            ["—", "—", "—", "—", "0/2", "Rust"]
4928        );
4929        assert_eq!(
4930            total.split_whitespace().take(6).collect::<Vec<_>>(),
4931            ["—", "—", "—", "—", "0/2", "TOTAL"]
4932        );
4933        assert!(!unmeasured.contains("gitignored"), "{unmeasured}");
4934        assert!(!unmeasured.contains("unsupported"), "coverage is a note, not a row: {unmeasured}");
4935        assert!(
4936            report_notes(&report).contains(&"note: not analyzed: 2 unsupported".to_owned()),
4937            "{:?}",
4938            report_notes(&report)
4939        );
4940        assert!(unmeasured.lines().all(|line| line.trim_end() == line), "{unmeasured:?}");
4941    }
4942
4943    #[test]
4944    fn percentage_and_unicode_width_keep_small_values_visible() {
4945        assert_eq!(human_percentage(1, 10_000, 1), "<0.1%");
4946        assert_eq!(human_percentage(1, 10_000, 0), "<1%");
4947        assert_eq!(human_percentage(0, 10_000, 1), "0.0%");
4948        assert_eq!(human_percentage(0, 0, 1), "—");
4949        assert_eq!(label_cell("界", 4, STYLE_CATEGORY, false), "界  ");
4950        assert_eq!(label_cell("e\u{301}", 4, STYLE_CATEGORY, false), "e\u{301}   ");
4951    }
4952
4953    #[test]
4954    fn no_machine_format_gains_a_text_header() {
4955        // Machine formats already name their view in a field. Text is a presentation
4956        // layer over the same report and must not leak into the versioned schemas.
4957        let views = [ViewSpec::Tree, ViewSpec::Types, ViewSpec::Files, ViewSpec::Summary];
4958        for format in [Format::Json, Format::Jsonl, Format::Yaml] {
4959            let rendered = render(&fixture(&views), format, false);
4960            for header in ["TREE", "TYPES", "FILES", "SUMMARY"] {
4961                assert!(!rendered.contains(header), "{format:?} leaked {header}:\n{rendered}");
4962            }
4963        }
4964    }
4965
4966    #[test]
4967    fn every_view_has_a_header_that_matches_its_wire_label() {
4968        // The two spellings are written out separately so a schema change and a
4969        // presentation change stay independent; this is what keeps them from drifting
4970        // apart by accident while they are meant to agree.
4971        for view in [
4972            ViewSpec::Tree,
4973            ViewSpec::Extensions,
4974            ViewSpec::Types,
4975            ViewSpec::Families,
4976            ViewSpec::Languages,
4977            ViewSpec::Documents,
4978            ViewSpec::Files,
4979            ViewSpec::Summary,
4980        ] {
4981            let header = view_header(view);
4982            assert_eq!(header, view.label().to_uppercase(), "{view:?}");
4983            assert!(
4984                !header.is_empty() && header.chars().all(|c| c.is_ascii_uppercase()),
4985                "{view:?}"
4986            );
4987        }
4988    }
4989
4990    #[test]
4991    fn yaml_quotes_only_what_would_be_ambiguous() {
4992        assert_eq!(yaml_scalar("cold_scan"), "cold_scan");
4993        assert_eq!(yaml_scalar("src/main.rs"), "src/main.rs");
4994        // Bare words YAML would read as another type have to be quoted.
4995        assert_eq!(yaml_scalar("true"), "\"true\"");
4996        assert_eq!(yaml_scalar("null"), "\"null\"");
4997        assert_eq!(yaml_scalar("12345"), "\"12345\"");
4998        assert_eq!(yaml_scalar(""), "\"\"");
4999        assert_eq!(yaml_scalar("has space"), "\"has space\"");
5000        assert_eq!(yaml_scalar("-leading-dash"), "\"-leading-dash\"");
5001    }
5002
5003    #[test]
5004    fn json_strings_escape_control_characters_and_quotes() {
5005        assert_eq!(quote("a\"b"), "\"a\\\"b\"");
5006        assert_eq!(quote("a\\b"), "\"a\\\\b\"");
5007        assert_eq!(quote("a\nb"), "\"a\\nb\"");
5008        assert_eq!(quote("a\u{1}b"), "\"a\\u0001b\"");
5009    }
5010
5011    #[test]
5012    fn format_values_parse_and_reject_by_name() {
5013        assert_eq!(Format::parse("json"), Some(Format::Json));
5014        assert_eq!(Format::parse("  YAML "), Some(Format::Yaml));
5015        assert_eq!(Format::parse("xml"), None);
5016        assert_eq!(Format::ALL.len(), 7);
5017    }
5018
5019    #[test]
5020    fn human_bytes_reads_at_scale() {
5021        assert_eq!(human_bytes(0), "0 B");
5022        assert_eq!(human_bytes(512), "512 B");
5023        assert_eq!(human_bytes(999), "999 B");
5024        assert_eq!(human_bytes(1000), "1,000 B");
5025        assert_eq!(human_bytes(1024), "1.0 KiB");
5026        assert_eq!(human_bytes(1024 * 1024 * 20), "20 MiB");
5027    }
5028
5029    #[test]
5030    fn human_counts_share_one_full_width_grouping_policy() {
5031        assert_eq!(human_count(999), "999");
5032        assert_eq!(human_count(1000), "1,000");
5033        assert_eq!(
5034            human_count_u128(u128::MAX),
5035            "340,282,366,920,938,463,463,374,607,431,768,211,455"
5036        );
5037        assert_eq!(human_age(Some(1000 * 86400 * 1_000_000_000)), "1,000d");
5038    }
5039
5040    #[test]
5041    fn bars_are_fixed_at_ten_cells_and_saturate() {
5042        assert_eq!(usage_bar(0, 100, Some(0), false, 10), "░░░░░░░░░░");
5043        assert_eq!(usage_bar(50, 100, Some(0), false, 10), "█████░░░░░");
5044        assert_eq!(usage_bar(200, 100, Some(0), false, 10), "██████████");
5045        assert!((ratio(5, 0) - 0.0).abs() < f64::EPSILON);
5046        assert_eq!(bar_cells(u64::MAX / 2, u64::MAX, 1), 0);
5047        assert_eq!(bar_cells(u64::MAX / 2 + 1, u64::MAX, 1), 1);
5048    }
5049
5050    #[test]
5051    fn human_styles_respect_exact_thresholds_and_directory_identity() {
5052        assert_eq!(styled_bytes(0, 0, true, false), "\x1b[90m0 B\x1b[0m");
5053        assert_eq!(styled_bytes(0, 0, false, false), "0 B");
5054        let gib = 1 << 30;
5055        assert_eq!(styled_bytes(gib - 1, 10, true, false), format!("{:>10}", human_bytes(gib - 1)));
5056        assert_eq!(
5057            styled_bytes(gib, 10, true, false),
5058            paint("   1.0 GiB", AnsiStyle::new().bold(), true)
5059        );
5060        assert_eq!(styled_bytes(gib, 0, true, true), paint("1.0 GiB", STYLE_DETAIL.bold(), true));
5061        assert_eq!(styled_bytes(gib, 0, false, true), "1.0 GiB");
5062        assert_eq!(percentage_cell(99, 10_000, 0, 5, true), detail("  <1%", true));
5063        assert_eq!(percentage_cell(100, 10_000, 0, 5, true), "   1%");
5064        assert_eq!(human_percentage(u64::MAX / 100, u64::MAX, 0), "<1%");
5065        assert_eq!(human_percentage(u64::MAX / 100 + 1, u64::MAX, 0), "1%");
5066        assert_eq!(human_percentage(u64::MAX / 1_000, u64::MAX, 1), "<0.1%");
5067        for name in [".", ".."] {
5068            assert_eq!(human_name(name, EntryKind::Dir, None, false), name);
5069        }
5070        assert_eq!(
5071            human_name("build", EntryKind::Dir, None, true),
5072            format!("{}{}", paint("build", STYLE_NAME, true), detail("/", true))
5073        );
5074        assert_eq!(human_name("build", EntryKind::File, None, false), "build");
5075        assert_eq!(human_name("build", EntryKind::Dir, None, false), "build/");
5076    }
5077
5078    #[test]
5079    fn only_own_ignored_directories_lose_bold_name_styling() {
5080        for ignored in [None, Some(false), Some(true)] {
5081            let style = if ignored == Some(true) { STYLE_IGNORED_NAME } else { STYLE_NAME };
5082            assert_eq!(
5083                human_name("node_modules", EntryKind::Dir, ignored, true),
5084                format!("{}{}", paint("node_modules", style, true), detail("/", true))
5085            );
5086            assert_eq!(
5087                human_name("file.rs", EntryKind::File, ignored, true),
5088                paint("file.rs", STYLE_NAME, true)
5089            );
5090            assert_eq!(human_name("node_modules", EntryKind::Dir, ignored, false), "node_modules/");
5091        }
5092    }
5093
5094    #[test]
5095    fn colored_bars_partition_selected_usage_and_keep_ten_cells() {
5096        let split = usage_bar(60, 100, Some(20), true, 10);
5097        assert_eq!(
5098            split,
5099            format!(
5100                "{}{}{}",
5101                paint("████", STYLE_BAR, true),
5102                paint("▓▓", STYLE_BAR, true),
5103                paint("░░░░", STYLE_BAR.dimmed(), true)
5104            )
5105        );
5106        assert_eq!(strip_ansi(&split).chars().count(), 10);
5107        assert_eq!(usage_bar(60, 100, Some(20), false, 10), "██████░░░░");
5108        assert_eq!(strip_ansi(&usage_bar(60, 60, Some(60), true, 10)), "▓▓▓▓▓▓▓▓▓▓");
5109        assert_eq!(
5110            usage_bar(60, 60, Some(60), true, 10),
5111            format!(
5112                "{}{}{}",
5113                paint("", STYLE_BAR, true),
5114                paint("▓▓▓▓▓▓▓▓▓▓", STYLE_BAR, true),
5115                paint("", STYLE_BAR.dimmed(), true)
5116            )
5117        );
5118        assert_eq!(strip_ansi(&usage_bar(0, 0, None, true, 10)), "░░░░░░░░░░");
5119        let mostly_ignored = usage_bar(130, 2_120, Some(96), true, 10);
5120        assert_eq!(strip_ansi(&mostly_ignored), "▓░░░░░░░░░");
5121        assert!(mostly_ignored.contains("\x1b[32m▓\x1b[0m"));
5122        assert_eq!(strip_ansi(&usage_bar(130, 2_120, Some(96), true, 20)), "▓░░░░░░░░░░░░░░░░░░░");
5123        assert_eq!(strip_ansi(&usage_bar(130, 2_120, Some(96), true, 100)).matches('▓').count(), 4);
5124    }
5125
5126    #[test]
5127    fn render_options_resize_or_remove_tree_bars_without_changing_data() {
5128        let report = fixture_for(&Query {
5129            views: vec![ViewSpec::Tree],
5130            selection: Selection {
5131                size: SizeMetric::Apparent,
5132                depth: Some(Bound::Limit(0)),
5133                ..Selection::default()
5134            },
5135            ..Query::default()
5136        });
5137        for width in [0, 10, 20] {
5138            let options = RenderOptions { color: false, bar_size: width };
5139            let text = render_with_options(&report, Format::Text, options).expect("tree");
5140            let lines = text.lines().collect::<Vec<_>>();
5141            assert_eq!(lines.len(), 2, "{width}: {text:?}");
5142            for line in &lines {
5143                if width == 0 {
5144                    assert!(line.starts_with(" 100%"), "{line:?}");
5145                } else {
5146                    assert!(line.starts_with(&"█".repeat(width)), "{line:?}");
5147                    assert!(line["█".repeat(width).len()..].starts_with("   100%"));
5148                }
5149            }
5150            assert!(lines[0].ends_with("120 B  . 2 files"), "{text:?}");
5151            assert!(lines[1].ends_with("120 B    … and 2 more files"), "{text:?}");
5152            let mut streamed = Vec::new();
5153            write_with_options(&report, Format::Text, options, &mut streamed).expect("stream");
5154            assert_eq!(streamed, text.as_bytes());
5155        }
5156        assert_eq!(
5157            render_with_options(&report, Format::Json, RenderOptions { color: true, bar_size: 20 })
5158                .expect("machine"),
5159            render(&report, Format::Json, false)
5160        );
5161        let excessive = RenderOptions { color: false, bar_size: MAX_BAR_SIZE + 1 };
5162        assert!(
5163            render_with_options(&report, Format::Text, excessive)
5164                .expect_err("tree width must be bounded")
5165                .to_string()
5166                .contains("bar_size")
5167        );
5168        let mut output = Vec::new();
5169        assert_eq!(
5170            write_with_options(&report, Format::Text, excessive, &mut output)
5171                .expect_err("streaming tree width must be bounded")
5172                .kind(),
5173            io::ErrorKind::InvalidInput
5174        );
5175        assert!(output.is_empty());
5176        let summary = fixture(&[ViewSpec::Summary]);
5177        assert_eq!(
5178            render_with_options(&summary, Format::Text, excessive).expect("no tree bar"),
5179            render(&summary, Format::Text, false)
5180        );
5181    }
5182
5183    const DEEP_RENDER_CHILD_ENV: &str = "FDU_DEEP_RENDER_CHILD";
5184    const DEEP_RENDER_DEPTH: usize = 1_024;
5185    const DEEP_REPORT_STACK_BYTES: usize = 128 * 1_024;
5186    const DEEP_RENDER_STACK_BYTES: usize = 64 * 1_024;
5187
5188    // ---- renderer tests that lived in the command line -------------------------------
5189    //
5190    // They test expansion and the three renderers, not argument handling, and they build
5191    // an index by hand -- which is why moving the CLI into its own crate surfaced them:
5192    // the fixture helpers they need are `pub(crate)` here and unreachable from there.
5193
5194    #[test]
5195    fn deep_rendering_is_stack_safe() {
5196        if std::env::var_os(DEEP_RENDER_CHILD_ENV).is_some() {
5197            run_deep_render_child();
5198            return;
5199        }
5200
5201        let started = Instant::now();
5202        let mut child = Command::new(std::env::current_exe().expect("current test executable"))
5203            .args(["--exact", DEEP_RENDER_TEST_PATH, "--nocapture"])
5204            .env(DEEP_RENDER_CHILD_ENV, "1")
5205            .stdin(Stdio::null())
5206            .stdout(Stdio::piped())
5207            .stderr(Stdio::piped())
5208            .spawn()
5209            .expect("spawn deep-render child");
5210        let stdout = drain_pipe(child.stdout.take().expect("piped stdout"));
5211        let stderr = drain_pipe(child.stderr.take().expect("piped stderr"));
5212        let status = loop {
5213            if let Some(status) = child.try_wait().expect("poll deep-render child") {
5214                break Some(status);
5215            }
5216            if started.elapsed() >= DEEP_RENDER_CHILD_TIMEOUT {
5217                break None;
5218            }
5219            std::thread::sleep(Duration::from_millis(50));
5220        };
5221        let Some(status) = status else {
5222            // `kill` is SIGKILL on Unix, which the child can neither catch nor ignore: the
5223            // hang this bounds also ignored SIGTERM. Its result can be ignored because the
5224            // child is not yet reaped, so even one that exited a moment ago is still there
5225            // to signal, and `wait` returns once it is gone.
5226            let _ = child.kill();
5227            let reaped = child.wait().expect("reap the killed deep-render child");
5228            panic!(
5229                "deep-render child still running after {DEEP_RENDER_CHILD_TIMEOUT:?}; killed at \
5230                 {:?} ({reaped}). The last `deep-render phase:` line is where it stalled.\n\
5231                 stdout:\n{}\nstderr:\n{}",
5232                started.elapsed(),
5233                stdout.join().expect("stdout reader"),
5234                stderr.join().expect("stderr reader")
5235            );
5236        };
5237        let stdout = stdout.join().expect("stdout reader");
5238        let stderr = stderr.join().expect("stderr reader");
5239        assert!(
5240            status.success(),
5241            "deep renderer failed in child process\nstdout:\n{stdout}\nstderr:\n{stderr}"
5242        );
5243
5244        // The exit code alone cannot tell "the deep render survived" from "the filter
5245        // matched nothing": libtest runs zero tests and exits 0 for a name that does not
5246        // exist, so a moved test would keep reporting success having stopped running --
5247        // which is what happened when this test moved out of `cli::tests` (fdu-rdom).
5248        assert!(
5249            stdout.contains("1 passed"),
5250            "the child must actually run the deep render, not filter it away\nstdout:\n{stdout}"
5251        );
5252    }
5253
5254    /// The child re-invocation filters on this, so it has to track the module the test
5255    /// lives in. Named once, beside the test, rather than spelled in the argument list
5256    /// where a move leaves it silently stale.
5257    const DEEP_RENDER_TEST_PATH: &str = "report_format::tests::deep_rendering_is_stack_safe";
5258
5259    /// How long the parent lets the deep-render child run before killing it.
5260    ///
5261    /// Without a bound a stuck child blocks the suite indefinitely: on 2026-09-28 one spun
5262    /// for more than 85 minutes (fdu-xsg1). Measured on Linux x86-64 debug with four
5263    /// cores, the child takes about 21 s alone and 28 s under the full parallel suite,
5264    /// nearly all of it building the 1,024-level fixture one batch at a time. Five minutes
5265    /// is ten times the loaded figure, room for a runner several times slower, and still
5266    /// turns a hang into a failure well before anyone would notice a stalled gate.
5267    const DEEP_RENDER_CHILD_TIMEOUT: Duration = Duration::from_secs(300);
5268
5269    /// Read a child's pipe to its end on a helper thread.
5270    ///
5271    /// One reader per pipe means a child that fills one pipe's buffer never blocks while
5272    /// the parent waits on the other pipe or on its exit, and what the child wrote before
5273    /// a hang is still there to report once it is killed.
5274    fn drain_pipe(mut pipe: impl io::Read + Send + 'static) -> std::thread::JoinHandle<String> {
5275        std::thread::spawn(move || {
5276            let mut bytes = Vec::new();
5277            // A read error ends the capture early; what arrived before it is still reported.
5278            let _ = pipe.read_to_end(&mut bytes);
5279            String::from_utf8_lossy(&bytes).into_owned()
5280        })
5281    }
5282
5283    fn run_deep_render_child() {
5284        // A deep tree must build and render without depth-recursive stack growth.
5285        // Windows reserves 20 KiB of a spawned thread's stack for overflow handling;
5286        // a 64 KiB reservation leaves too little dependable room for report setup in
5287        // debug builds. Keep construction bounded at 128 KiB, then test rendering and
5288        // release separately on the original 64 KiB stack.
5289        eprintln!("deep-render phase: fixture");
5290        let mut index = crate::Index::new("/fixture");
5291        let mut path = PathBuf::new();
5292        for depth in 0..DEEP_RENDER_DEPTH {
5293            path.push("d");
5294            index.apply_ok(&crate::Observation::new(vec![crate::Op::Upsert {
5295                path: path.clone(),
5296                kind: EntryKind::Dir,
5297                attrs: crate::Attrs {
5298                    mtime_ns: i64::try_from(depth).expect("fixture depth fits i64"),
5299                    ..Default::default()
5300                },
5301            }]));
5302        }
5303        index.set_initial_freshness(false);
5304
5305        let report = std::thread::Builder::new()
5306            .name("deep-report".to_string())
5307            .stack_size(DEEP_REPORT_STACK_BYTES)
5308            .spawn(move || {
5309                let query = Query {
5310                    selection: Selection {
5311                        depth: Some(Bound::All),
5312                        breadth: Some(Bound::All),
5313                        limit: Some(Bound::All),
5314                        min_share: Some(ShareThreshold::parse("0%").expect("zero share is valid")),
5315                        ..Selection::default()
5316                    },
5317                    views: vec![ViewSpec::Tree],
5318                    ..Query::default()
5319                };
5320                let provenance = Provenance {
5321                    scan_started_at: None,
5322                    generated_at: SystemTime::UNIX_EPOCH,
5323                    source: ReportSource::ColdScan,
5324                    complete: true,
5325                    errors: Vec::new(),
5326                };
5327                eprintln!("deep-render phase: request");
5328                let request = crate::test_support::read_of(&index, query);
5329                eprintln!("deep-render phase: report");
5330                let report = report(&index, &request, &provenance).expect("report");
5331                eprintln!("deep-render phase: verify tree");
5332                let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
5333                    panic!("expected a tree section with a root")
5334                };
5335                assert!(omissions.is_empty(), "the root must not be omitted");
5336                let mut nodes = 0;
5337                let mut pending = vec![root.as_ref()];
5338                while let Some(node) = pending.pop() {
5339                    nodes += 1;
5340                    assert!(node.omissions.is_empty(), "no branch may be omitted: {:?}", node.path);
5341                    pending.extend(node.children.iter());
5342                }
5343                assert_eq!(nodes, DEEP_RENDER_DEPTH + 1, "the test must reach every directory");
5344                report
5345            })
5346            .expect("spawn deep-report thread")
5347            .join()
5348            .expect("deep-report thread");
5349
5350        std::thread::Builder::new()
5351            .name("deep-render".to_string())
5352            .stack_size(DEEP_RENDER_STACK_BYTES)
5353            .spawn(move || {
5354                for format in [Format::Text, Format::Json, Format::Jsonl, Format::Yaml] {
5355                    eprintln!("deep-render phase: render {format:?}");
5356                    let rendered = render(&report, format, false);
5357                    assert!(!rendered.is_empty(), "{format:?} rendered nothing for a deep tree");
5358                    if format != Format::Text {
5359                        eprintln!("deep-render phase: stream {format:?}");
5360                        let mut streamed = Vec::new();
5361                        write(&report, format, false, &mut streamed).expect("stream deep report");
5362                        assert_eq!(streamed, rendered.as_bytes(), "{format:?} bytes differ");
5363                    }
5364                }
5365                eprintln!("deep-render phase: drop");
5366                drop(report);
5367                eprintln!("deep-render phase: complete");
5368            })
5369            .expect("spawn deep-render thread")
5370            .join()
5371            .expect("deep-render thread");
5372    }
5373
5374    /// Two names that differ only in bytes `to_string_lossy` cannot represent must stay
5375    /// distinguishable in machine output.
5376    ///
5377    /// This coverage was lost when the CLI moved to the five axes: `raw_identity_json`
5378    /// survived the rewrite, its tests did not, and the merge from PR #6 is what surfaced
5379    /// the gap. Retargeted here to the report path rather than restored to the old
5380    /// `write_json`, because the guarantee belongs to the format, not to the flag that
5381    /// used to select it.
5382    fn assert_json_preserves_raw_identity(
5383        root: PathBuf,
5384        first: &OsStr,
5385        second: &OsStr,
5386        encoding: &str,
5387        root_hex: &str,
5388        first_hex: &str,
5389        second_hex: &str,
5390    ) {
5391        // The premise: lossy rendering collapses these two into the same string, so a
5392        // consumer with only `name` cannot tell them apart.
5393        assert_eq!(first.to_string_lossy(), second.to_string_lossy());
5394
5395        let mut index = crate::Index::new(root);
5396        index.apply_ok(&crate::Observation::new(vec![
5397            crate::Op::Upsert {
5398                path: PathBuf::from(first),
5399                kind: EntryKind::File,
5400                attrs: crate::Attrs { size: 1, allocated: 1, ..Default::default() },
5401            },
5402            crate::Op::Upsert {
5403                path: PathBuf::from(second),
5404                kind: EntryKind::File,
5405                attrs: crate::Attrs { size: 1, allocated: 1, ..Default::default() },
5406            },
5407        ]));
5408        index.set_initial_freshness(false);
5409
5410        // Built directly rather than through the command line's argument struct: what is
5411        // under test is that the renderer preserves a non-UTF-8 name's raw identity, and
5412        // routing that through argument parsing tied a renderer test to a front end.
5413        let query = crate::query::Query {
5414            views: vec![ViewSpec::Files],
5415            selection: Selection { limit: Some(crate::query::Bound::All), ..Selection::default() },
5416            ..crate::query::Query::default()
5417        };
5418        let provenance = Provenance {
5419            scan_started_at: None,
5420            generated_at: std::time::UNIX_EPOCH,
5421            source: ReportSource::ColdScan,
5422            complete: true,
5423            errors: Vec::new(),
5424        };
5425        let files_report =
5426            report(&index, &crate::test_support::read_of(&index, query.clone()), &provenance)
5427                .expect("report");
5428        let rendered = render(&files_report, Format::Json, false);
5429        let mut checked = SchemaCheck::report(JsonSink::pretty(), true, true);
5430        emit_report(&mut checked, &files_report, true);
5431        assert_eq!(checked.finish(), rendered);
5432
5433        let lossy = first.to_string_lossy();
5434        assert_eq!(
5435            rendered.matches(&format!("\"{lossy}\"")).count(),
5436            2,
5437            "both names render the same lossy text: {rendered}"
5438        );
5439        assert!(
5440            rendered.contains(&format!(
5441                "\"root_raw\": {{\"encoding\": \"{encoding}\", \"hex\": \"{root_hex}\"}}"
5442            )),
5443            "{rendered}"
5444        );
5445
5446        // Pinned as the whole row rather than as a substring of it. A loose `contains`
5447        // check on the `path_raw` object alone passed while the row around it was
5448        // malformed -- the field was emitted with a duplicated separator and a newline
5449        // inside a one-line object, so the document did not parse at all. Asserting the
5450        // exact row is what makes the surrounding punctuation part of the contract.
5451        for hex in [first_hex, second_hex] {
5452            let row = format!(
5453                "{{\"path\": \"{lossy}\", \"path_raw\": {{\"encoding\": \"{encoding}\", \"hex\": \"{hex}\"}}, \
5454                 \"kind\": \"file\", \"bytes\": 1, \"allocated\": 1, \"mtime_ns\": 0, \
5455                 \"files\": null, \"dirs\": null, \"complete\": null, \"age_ns\": 0, \"ignored\": false, \"sort_value\": null, \
5456                 \"classification\": {{\"file_type\": \"unknown\", \"family\": \"unknown\", \"source\": \"unknown\", \"confidence\": \"heuristic\", \
5457                 \"flags\": {{\"generated\": false, \"vendored\": false, \"documentation\": false}}}}}}"
5458            );
5459            assert!(
5460                compact_json(&rendered).contains(&compact_json(&row)),
5461                "a name that is not valid Unicode must carry its raw bytes in a well-formed \
5462                 row.\nexpected: {row}\nrendered: {rendered}"
5463            );
5464        }
5465
5466        // Cheap structural guard against the same class of mistake anywhere else in the
5467        // document: an empty element is the signature of a separator emitted twice.
5468        assert!(
5469            !rendered.contains(", ,") && !rendered.contains(",,"),
5470            "duplicated separator in machine output: {rendered}"
5471        );
5472
5473        // The tree writer names entries too, and carried the identical defect. Pinning
5474        // only the files view would have left half the fix untested. A tree lists
5475        // directories, so the case has to be a directory whose own name is not valid
5476        // Unicode rather than the files above.
5477        let mut dirs = crate::Index::new(PathBuf::from("/tree-fixture"));
5478        dirs.apply_ok(&crate::Observation::new(vec![
5479            crate::Op::Upsert {
5480                path: PathBuf::from(first),
5481                kind: EntryKind::Dir,
5482                attrs: crate::Attrs { size: 0, allocated: 0, ..Default::default() },
5483            },
5484            crate::Op::Upsert {
5485                path: PathBuf::from(first).join("inside.txt"),
5486                kind: EntryKind::File,
5487                attrs: crate::Attrs { size: 1, allocated: 1, ..Default::default() },
5488            },
5489        ]));
5490        dirs.set_initial_freshness(false);
5491        let tree_query = crate::query::Query {
5492            views: vec![ViewSpec::Tree],
5493            selection: Selection {
5494                depth: Some(crate::query::Bound::All),
5495                limit: Some(crate::query::Bound::All),
5496                ..Selection::default()
5497            },
5498            ..crate::query::Query::default()
5499        };
5500        let tree =
5501            report(&dirs, &crate::test_support::read_of(&dirs, tree_query.clone()), &provenance)
5502                .expect("report");
5503        let tree_rendered = render(&tree, Format::Json, false);
5504        assert!(
5505            compact_json(&tree_rendered).contains(&compact_json(&format!(
5506                ", \"path_raw\": {{\"encoding\": \"{encoding}\", \"hex\": \"{first_hex}\"}}, \"kind\":"
5507            ))),
5508            "the tree view must carry raw identity in a well-formed node: {tree_rendered}"
5509        );
5510        assert!(
5511            !tree_rendered.contains(", ,") && !tree_rendered.contains(",,"),
5512            "duplicated separator in tree output: {tree_rendered}"
5513        );
5514    }
5515
5516    #[cfg(unix)]
5517    #[test]
5518    fn json_preserves_distinct_non_unicode_unix_names() {
5519        use std::ffi::OsString;
5520        use std::os::unix::ffi::OsStringExt;
5521
5522        assert_json_preserves_raw_identity(
5523            PathBuf::from(OsString::from_vec(vec![b'/', 0x80])),
5524            &OsString::from_vec(vec![b'n', 0x80]),
5525            &OsString::from_vec(vec![b'n', 0x81]),
5526            "unix-bytes",
5527            "2f80",
5528            "6e80",
5529            "6e81",
5530        );
5531    }
5532
5533    #[cfg(windows)]
5534    #[test]
5535    fn json_preserves_distinct_non_unicode_windows_names() {
5536        use std::ffi::OsString;
5537        use std::os::windows::ffi::OsStringExt;
5538
5539        assert_json_preserves_raw_identity(
5540            PathBuf::from(OsString::from_wide(&[u16::from(b'R'), u16::from(b':'), 0xd800])),
5541            &OsString::from_wide(&[u16::from(b'n'), 0xd800]),
5542            &OsString::from_wide(&[u16::from(b'n'), 0xd801]),
5543            "windows-wtf16le",
5544            "52003a0000d8",
5545            "6e0000d8",
5546            "6e0001d8",
5547        );
5548    }
5549}