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