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        CoverageReason::TextOnly => "counted as text",
1880    }
1881}
1882
1883/// The gitignored subset a text row ends with, as ` (128 B gitignored)`, or nothing.
1884/// This amount is already included in the row total, not additional usage.
1885///
1886/// One placement for every row that carries a share: after the row's own detail, so the
1887/// fixed size, bar, and percentage columns keep their alignment. Nothing is appended when
1888/// no file is ignored, when the index observed no control state, or when the selection
1889/// admitted only ignored entries, where the share would repeat the row's size. A share of
1890/// ignored directories alone holds no bytes, and `(0 B gitignored)` would say nothing a
1891/// reader can act on; the machine formats still count them. Text cannot tell "nothing
1892/// ignored" from "no rules read"; the performance line says whether any rule was read,
1893/// and machine formats carry a zero share and `null` respectively.
1894fn ignored_suffix(
1895    ignored: Option<IgnoredTally>,
1896    size: SizeMetric,
1897    selected: IgnoredEntries,
1898    color: bool,
1899) -> String {
1900    let shown = match selected {
1901        IgnoredEntries::Include | IgnoredEntries::Exclude => {
1902            ignored.filter(|share| share.files > 0)
1903        }
1904        IgnoredEntries::Only => None,
1905    };
1906    shown.map_or_else(String::new, |share| {
1907        let bytes = pick(size, share.bytes, share.allocated);
1908        if bytes < 1 << 30 || !color {
1909            format!(" {}", detail(&format!("({} gitignored)", human_bytes(bytes)), color))
1910        } else {
1911            format!(
1912                " {}{}{}",
1913                detail("(", color),
1914                styled_bytes(bytes, 0, color, true),
1915                detail(" gitignored)", color)
1916            )
1917        }
1918    })
1919}
1920
1921/// Render a tree section with fixed bar, percentage, and size columns.
1922///
1923/// Iterative for the same reason the expansion is: a deep tree must render, not panic.
1924fn render_text_tree(
1925    out: &mut String,
1926    root: Option<&TreeNode>,
1927    omissions: &[crate::query::TreeOmission],
1928    _limits: &crate::query::TreeDisplayLimits,
1929    size: SizeMetric,
1930    selected: IgnoredEntries,
1931    options: RenderOptions,
1932) {
1933    let RenderOptions { color, bar_size } = options;
1934    let hidden = crate::query::TreeRemainder::from_tree(root, omissions);
1935    if let Some(root) = root {
1936        let grand = pick(size, root.bytes, root.allocated);
1937        let mut stack = vec![(root, 0)];
1938        while let Some((node, depth)) = stack.pop() {
1939            let bytes = pick(size, node.bytes, node.allocated);
1940            let indent = "  ".repeat(depth);
1941            let count = if node.kind == EntryKind::File {
1942                String::new()
1943            } else {
1944                format!(" {} {}", human_count(node.files), plural(node.files, "file", "files"))
1945            };
1946            let bar_prefix = if bar_size == 0 {
1947                String::new()
1948            } else {
1949                format!(
1950                    "{}  ",
1951                    usage_bar(
1952                        bytes,
1953                        grand,
1954                        node.ignored.map(|value| pick(size, value.bytes, value.allocated)),
1955                        color,
1956                        bar_size,
1957                    )
1958                )
1959            };
1960            let _ = writeln!(
1961                out,
1962                "{bar_prefix}{}  {}  {indent}{}{}{}",
1963                percentage_cell(bytes, grand, 0, 5, color),
1964                styled_bytes(bytes, 10, color, false),
1965                human_name(&node.name, node.kind, node.entry_ignored, color),
1966                count,
1967                ignored_suffix(node.ignored, size, selected, color),
1968            );
1969            stack.extend(node.children.iter().rev().map(|child| (child, depth + 1)));
1970        }
1971    }
1972    // One annotation at the highest displayed level, even when several independent
1973    // bounds hide descendants at different depths. Reasons belong in the epilogue.
1974    if let Some(hidden) = hidden {
1975        let grand = root.map(|node| pick(size, node.bytes, node.allocated));
1976        render_tree_remainder(out, &hidden, grand, usize::from(root.is_some()), size, options);
1977    }
1978}
1979
1980/// Human projection of the same remainder serialized in machine formats.
1981fn render_tree_remainder(
1982    out: &mut String,
1983    remainder: &crate::query::TreeRemainder,
1984    grand: Option<u64>,
1985    depth: usize,
1986    size: SizeMetric,
1987    options: RenderOptions,
1988) {
1989    let RenderOptions { color, bar_size } = options;
1990    let bytes = match size {
1991        SizeMetric::Apparent => remainder.bytes,
1992        SizeMetric::Allocated => remainder.allocated,
1993    };
1994    let measure = bytes.map_or_else(
1995        || detail(&format!("{:>10}", "unknown"), color),
1996        |bytes| styled_bytes(bytes, 10, color, false),
1997    );
1998    // With no visible root, a known remainder represents the whole selected root.
1999    // A missing measurement cannot honestly produce either a bar or a percentage.
2000    let (usage_bar, percentage) = match bytes.zip(grand.or(bytes)) {
2001        Some((bytes, grand)) => (
2002            usage_bar(
2003                bytes,
2004                grand,
2005                remainder.ignored.map(|value| pick(size, value.bytes, value.allocated)),
2006                color,
2007                bar_size,
2008            ),
2009            percentage_cell(bytes, grand, 0, 5, color),
2010        ),
2011        None => (" ".repeat(bar_size), detail(&format!("{:>5}", "—"), color)),
2012    };
2013    let files = remainder.files.map_or_else(
2014        || format!("more files {}", detail("(count unknown)", color)),
2015        |files| format!("{} more {}", human_count(files), plural(files, "file", "files")),
2016    );
2017    let indent = "  ".repeat(depth);
2018    let note = format!("{} {files}", detail(&format!("{indent}… and"), color));
2019    let bar_prefix = if bar_size == 0 { String::new() } else { format!("{usage_bar}  ") };
2020    let _ = writeln!(out, "{bar_prefix}{percentage}  {measure}  {note}");
2021}
2022
2023/// Render a types section as aligned rows.
2024fn render_text_types(
2025    out: &mut String,
2026    rows: &[TypeRow],
2027    size: SizeMetric,
2028    selected: IgnoredEntries,
2029    color: bool,
2030) {
2031    let width = label_width(rows.iter().map(|row| row.extension.as_str()), TEXT_TYPE_LABEL_WIDTH);
2032    for row in rows {
2033        let _ = writeln!(
2034            out,
2035            "{}  {} {} {}{}",
2036            styled_bytes(pick(size, row.bytes, row.allocated), TEXT_SIZE_WIDTH, color, false),
2037            label_cell(&escaped_human(&row.extension), width, STYLE_CATEGORY, color),
2038            human_count(row.files),
2039            plural(row.files, "file", "files"),
2040            ignored_suffix(row.ignored, size, selected, color),
2041        );
2042    }
2043}
2044
2045/// Counts row omissions after share filtering, so a share threshold alone never
2046/// produces a row-limit remedy.
2047fn bounded_rows(section: &Section) -> Option<(usize, usize)> {
2048    let (shown, total) = match section {
2049        Section::Code(overview) => (overview.languages.len(), overview.total_languages),
2050        Section::Files { rows, total, .. } => (rows.len(), *total),
2051        Section::Extensions { rows, total, .. } => (rows.len(), *total),
2052        Section::Metrics { summary, .. } => (summary.rows.len(), summary.total_rows),
2053        // Trees carry their own omission records; summary cannot be bounded.
2054        Section::Tree { .. } | Section::Summary(_) => return None,
2055    };
2056    (shown < total).then_some((shown, total))
2057}
2058
2059/// Factual row count near a bounded section. Actionable guidance is emitted once at
2060/// the end of the report by the shared diagnostic collector.
2061fn bound_note(section: &Section) -> String {
2062    let Some((shown, total)) = bounded_rows(section) else {
2063        return String::new();
2064    };
2065    format!("  ({} of {})", human_count(shown as u64), human_count(total as u64))
2066}
2067
2068/// A bounded flat listing, showing the measure it was ranked by.
2069///
2070/// The measure comes first at a fixed width so the paths line up under it, matching the
2071/// grouped views' size-then-label shape rather than inventing a third layout.
2072fn render_text_ranked_files(
2073    out: &mut String,
2074    rows: &[FileRow],
2075    color: bool,
2076    size: Option<SizeMetric>,
2077    measure: impl Fn(&FileRow) -> String,
2078) {
2079    let width = rows.iter().map(|row| display_width(&measure(row))).max().unwrap_or_default();
2080    for row in rows {
2081        let value = size.map_or_else(
2082            || format!("{:>width$}", measure(row)),
2083            |size| styled_bytes(pick(size, row.bytes, row.allocated), width, color, false),
2084        );
2085        let _ = writeln!(
2086            out,
2087            "{}  {}",
2088            value,
2089            human_name(&row.path.to_string_lossy(), row.kind, row.ignored, color)
2090        );
2091    }
2092}
2093
2094fn render_text_metric_files(out: &mut String, rows: &[FileRow], color: bool) {
2095    let width = rows
2096        .iter()
2097        .map(|row| row.sort_value.map_or(1, |value| human_count(value).len()))
2098        .max()
2099        .unwrap_or_default();
2100    for row in rows {
2101        let value = row.sort_value.map_or_else(|| "—".to_string(), human_count);
2102        let classification =
2103            row.classification.as_ref().map_or_else(String::new, |classification| {
2104                let mut parts =
2105                    vec![classification.file_type.as_str(), classification.source.as_str()];
2106                if classification.flags.generated {
2107                    parts.push("generated");
2108                }
2109                if classification.flags.vendored {
2110                    parts.push("vendored");
2111                }
2112                if classification.flags.documentation {
2113                    parts.push("documentation");
2114                }
2115                format!(" {}", detail(&format!("({})", parts.join(", ")), color))
2116            });
2117        let _ = writeln!(
2118            out,
2119            "{:>width$}  {}{}",
2120            value,
2121            human_name(&row.path.to_string_lossy(), row.kind, row.ignored, color),
2122            classification
2123        );
2124    }
2125}
2126
2127fn render_text_summary(
2128    out: &mut String,
2129    row: &SummaryRow,
2130    size: SizeMetric,
2131    selected: IgnoredEntries,
2132    color: bool,
2133) {
2134    let _ = writeln!(
2135        out,
2136        "{}  {} {}, {} {}{}",
2137        styled_bytes(pick(size, row.bytes, row.allocated), 10, color, false),
2138        human_count(row.files),
2139        plural(row.files, "file", "files"),
2140        human_count(row.dirs),
2141        plural(row.dirs, "directory", "directories"),
2142        ignored_suffix(row.ignored, size, selected, color),
2143    );
2144}
2145
2146/// Quote a YAML scalar whenever a bare word would be ambiguous.
2147///
2148/// Always quoting would be simpler and uglier; quoting only what needs it keeps the
2149/// output readable, which is the reason to offer YAML at all.
2150#[cfg(test)]
2151fn yaml_scalar(value: &str) -> String {
2152    let mut out = String::new();
2153    crate::emit::write_yaml_scalar(&mut out, value);
2154    out
2155}
2156
2157/// Quote and escape a string as a JSON scalar.
2158#[cfg(test)]
2159fn quote(text: &str) -> String {
2160    let mut out = String::with_capacity(text.len() + 2);
2161    crate::emit::write_json_string(&mut out, text);
2162    out
2163}
2164
2165/// This binary's identity, for the `generator` field.
2166fn generator() -> String {
2167    format!("fdu {}", env!("CARGO_PKG_VERSION"))
2168}
2169
2170/// All-caps header naming a view in multi-view text output.
2171///
2172/// Deliberately not `view.label().to_uppercase()`: the wire label is a schema promise
2173/// machine consumers match on, and deriving the human header from it would let a
2174/// presentation change reach into the schema, or freeze the schema for a presentation
2175/// reason. They spell the same word today because the same word is right in both places,
2176/// and a test holds them in step rather than a shared expression.
2177fn view_header(view: ViewSpec) -> &'static str {
2178    match view {
2179        ViewSpec::List => "LIST",
2180        ViewSpec::Tree => "TREE",
2181        ViewSpec::Types => "TYPES",
2182        ViewSpec::Extensions => "EXTENSIONS",
2183        ViewSpec::Families => "FAMILIES",
2184        ViewSpec::Languages => "LANGUAGES",
2185        ViewSpec::Code => "CODE",
2186        ViewSpec::Documents => "DOCUMENTS",
2187        ViewSpec::Files => "FILES",
2188        ViewSpec::Largest => "LARGEST",
2189        ViewSpec::Recent => "RECENT",
2190        ViewSpec::Summary => "SUMMARY",
2191    }
2192}
2193
2194fn metric_group_label(group: MetricGroup) -> &'static str {
2195    match group {
2196        MetricGroup::Type => "type",
2197        MetricGroup::Family => "family",
2198    }
2199}
2200
2201fn coverage_label(reason: CoverageReason) -> &'static str {
2202    match reason {
2203        CoverageReason::Analyzed => "analyzed",
2204        CoverageReason::Binary => "binary",
2205        CoverageReason::InvalidUtf8 => "invalid_utf8",
2206        CoverageReason::UnsupportedEncoding => "unsupported_encoding",
2207        CoverageReason::Unsupported => "unsupported",
2208        CoverageReason::IoError => "io_error",
2209        CoverageReason::ChangedDuringRead => "changed_during_read",
2210        CoverageReason::TextOnly => "text_only",
2211    }
2212}
2213
2214/// The requested analyzer set, in the vocabulary `--analyze` accepts.
2215///
2216/// A list rather than one label because the set is what was requested; the neighbouring
2217/// `analyzers` array reports what actually ran, with each dialect's version.
2218fn analysis_set_labels(profile: crate::content::AnalysisSet) -> Vec<&'static str> {
2219    profile.labels()
2220}
2221
2222/// Stable wire label for a cache tier.
2223fn source_label(source: ReportSource) -> &'static str {
2224    match source {
2225        ReportSource::ColdScan => "cold_scan",
2226        ReportSource::WarmRevalidate => "warm_revalidate",
2227        ReportSource::CacheOnly => "cache_only",
2228    }
2229}
2230
2231fn tier_source_label(source: Source) -> &'static str {
2232    match source {
2233        Source::Scanned => "scanned",
2234        Source::Revalidated => "revalidated",
2235        Source::JournalScoped => "journal_scoped",
2236        Source::Cached => "cached",
2237    }
2238}
2239
2240fn structural_coverage_label(reason: crate::engine_contract::CoverageReason) -> &'static str {
2241    match reason {
2242        crate::engine_contract::CoverageReason::Building => "building",
2243        crate::engine_contract::CoverageReason::Budget => "budget",
2244        crate::engine_contract::CoverageReason::Cancelled => "cancelled",
2245        crate::engine_contract::CoverageReason::Inaccessible => "inaccessible",
2246        crate::engine_contract::CoverageReason::Failed => "failed",
2247    }
2248}
2249
2250fn issue_kind_label(kind: IssueKind) -> &'static str {
2251    match kind {
2252        IssueKind::Permission => "permission",
2253        IssueKind::Disappeared => "disappeared",
2254        IssueKind::InvalidMetadata => "invalid_metadata",
2255        IssueKind::ResourceBudget => "resource_budget",
2256        IssueKind::ObservationGap => "observation_gap",
2257        IssueKind::ProviderFailure => "provider_failure",
2258    }
2259}
2260
2261/// Stable wire label for freshness.
2262fn freshness_label(freshness: Freshness) -> &'static str {
2263    match freshness {
2264        Freshness::Fresh => "fresh",
2265        Freshness::Reconciling => "reconciling",
2266        Freshness::Stale => "stale",
2267        Freshness::Partial => "partial",
2268    }
2269}
2270
2271/// Stable wire label for an entry kind.
2272fn kind_label(kind: EntryKind) -> &'static str {
2273    match kind {
2274        EntryKind::File => "file",
2275        EntryKind::Dir => "dir",
2276        EntryKind::Symlink => "symlink",
2277        EntryKind::Other => "other",
2278    }
2279}
2280
2281/// The byte count for the metric a report answers in.
2282fn pick(size: SizeMetric, apparent: u64, allocated: u64) -> u64 {
2283    match size {
2284        SizeMetric::Apparent => apparent,
2285        SizeMetric::Allocated => allocated,
2286    }
2287}
2288
2289/// Pick the singular or plural noun for a count.
2290fn plural<'a>(count: u64, singular: &'a str, plural: &'a str) -> &'a str {
2291    if count == 1 { singular } else { plural }
2292}
2293
2294/// A bounded share for the human size bar and percentage.
2295fn ratio(part: u64, whole: u64) -> f64 {
2296    if whole == 0 {
2297        return 0.0;
2298    }
2299    #[allow(clippy::cast_precision_loss)]
2300    let share = part as f64 / whole as f64;
2301    share.clamp(0.0, 1.0)
2302}
2303
2304// Rounding a bounded share to a requested bar width is presentation arithmetic:
2305// clamp the ratio and the resulting cell count before constructing the glyphs.
2306/// Nearest whole-cell share, with half cells rounded up and no floating-point loss.
2307fn bar_cells(part: u64, whole: u64, width: usize) -> usize {
2308    if whole == 0 {
2309        return 0;
2310    }
2311    let numerator = u128::from(part.min(whole)) * width as u128;
2312    usize::try_from((numerator + u128::from(whole) / 2) / u128::from(whole))
2313        .expect("a bounded share fits the supplied width")
2314}
2315
2316/// Split a colored bar into measured populations; plain bars retain their glyphs.
2317/// Round the total against the root, then apportion its visible cells by the row's
2318/// population ratio. Independent root-relative rounding can erase a majority ignored
2319/// population in a one-cell bar. Shading distinguishes populations within green;
2320/// unknown classification uses medium shading. Numeric columns remain authoritative.
2321fn usage_bar(bytes: u64, total: u64, ignored: Option<u64>, color: bool, width: usize) -> String {
2322    if width == 0 {
2323        return String::new();
2324    }
2325    let filled = bar_cells(bytes, total, width);
2326    if !color {
2327        return format!("{}{}", "█".repeat(filled), "░".repeat(width - filled));
2328    }
2329    if ignored.is_none() {
2330        return format!(
2331            "{}{}",
2332            paint(&"▒".repeat(filled), STYLE_BAR, true),
2333            paint(&"░".repeat(width - filled), STYLE_BAR.dimmed(), true)
2334        );
2335    }
2336    let ignored = bar_cells(ignored.unwrap_or(0), bytes, filled);
2337    format!(
2338        "{}{}{}",
2339        paint(&"█".repeat(filled - ignored), STYLE_BAR, true),
2340        paint(&"▓".repeat(ignored), STYLE_BAR, true),
2341        paint(&"░".repeat(width - filled), STYLE_BAR.dimmed(), true)
2342    )
2343}
2344
2345/// Render a count with thousands separators, the way every fdu report does.
2346///
2347/// Lived in the command line, and `report_format` called *into* it -- so the library
2348/// depended on its own front end, which is the inverse of the rule that the CLI invents
2349/// nothing. A crate boundary rejects that outright, which is how it was found.
2350pub fn human_count(value: u64) -> String {
2351    human_count_u128(u128::from(value))
2352}
2353
2354/// Group a full-width count using the same human policy as [`human_count`].
2355///
2356/// Keep the grouping rule here so a future locale or no-grouping choice changes both
2357/// widths together without losing precision in rates wider than u64.
2358pub fn human_count_u128(value: u128) -> String {
2359    let digits = value.to_string();
2360    let mut grouped = String::with_capacity(digits.len() + digits.len() / 3);
2361    for (index, byte) in digits.bytes().enumerate() {
2362        if index > 0 && (digits.len() - index) % 3 == 0 {
2363            grouped.push(',');
2364        }
2365        grouped.push(char::from(byte));
2366    }
2367    grouped
2368}
2369
2370/// Render a byte count at human scale, the way every fdu report does.
2371///
2372/// Public because a caller formatting fdu's numbers should not reimplement its unit
2373/// rules: two spellings of one quantity inside a single tool is how a report and the
2374/// line summarising it come to disagree.
2375pub fn human_bytes(bytes: u64) -> String {
2376    const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
2377    // Integer arithmetic to the unit, then one bounded division for the tenths digit:
2378    // a byte count can exceed f64's exact-integer range, and a size that renders wrong
2379    // at the top of the scale is worse than one that renders plainly.
2380    let mut whole = bytes;
2381    let mut remainder = 0u64;
2382    let mut unit = 0;
2383    while whole >= 1024 && unit + 1 < UNITS.len() {
2384        remainder = whole % 1024;
2385        whole /= 1024;
2386        unit += 1;
2387    }
2388    if unit == 0 {
2389        format!("{} B", human_count(bytes))
2390    } else if whole < 10 {
2391        let tenths = (remainder * 10) / 1024;
2392        format!("{}.{tenths} {}", human_count(whole), UNITS[unit])
2393    } else {
2394        format!("{} {}", human_count(whole), UNITS[unit])
2395    }
2396}
2397
2398/// Whether a path renders losslessly as UTF-8.
2399///
2400/// Non-UTF-8 names exist and a report must not pretend otherwise; the CLI layer adds the
2401/// raw-bytes companion field, and this is the predicate that decides when.
2402pub fn is_lossy(path: &Path) -> bool {
2403    path.to_str().is_none()
2404}
2405
2406/// Streaming-output schema identity.
2407///
2408/// Distinct from the one-shot schema on purpose: a stream is a sequence of tagged
2409/// records over time, not one document, and a consumer should not have to discover which
2410/// it is holding.
2411pub const STREAM_SCHEMA: &str = "fdu.stream/2";
2412
2413/// The rule drawn above a watch repaint, carrying the instant it was rendered.
2414///
2415/// The time is what makes the rule worth a line rather than a bare separator: a watch
2416/// reader wants to know when the tree last moved, and it is the one fact that
2417/// distinguishes one repaint from another whose numbers happen to match. RFC 3339 in UTC
2418/// is the spelling every other timestamp this tool prints uses.
2419///
2420/// Lives here rather than in the command line because it is presentation, and a caller
2421/// repainting fdu's views should draw fdu's separator rather than invent one that will
2422/// drift from it.
2423pub fn watch_rule(at: std::time::SystemTime) -> String {
2424    format!("──── {} ────", format_rfc3339(at))
2425}
2426
2427/// The watch repaint rule for an integer nanosecond timestamp.
2428///
2429/// This is distinct from [`watch_rule`] because an integer can carry finer precision than
2430/// the platform's [`std::time::SystemTime`]. In particular, Windows would otherwise
2431/// truncate the final two digits while converting through 100-nanosecond FILETIME ticks.
2432pub fn watch_rule_nanos(at_nanos: i64) -> String {
2433    format!("──── {} ────", format_rfc3339_nanos(at_nanos))
2434}
2435
2436/// Render one streamed change as a tagged record.
2437#[cfg(feature = "watch")]
2438pub fn render_change(change: &crate::Change, format: Format) -> String {
2439    let kind = match change.kind {
2440        crate::ChangeKind::Upsert => "upsert",
2441        crate::ChangeKind::Remove => "remove",
2442        crate::ChangeKind::Invalidate => "invalidate",
2443    };
2444
2445    if !format.is_machine() {
2446        // Path first, so the stream stays greppable and cuts the same way a one-shot
2447        // listing does; the operation follows on the same line.
2448        return format!("{}\t{kind}", change.path.display());
2449    }
2450
2451    match format {
2452        Format::Json | Format::Jsonl => render_change_machine(change, kind, JsonSink::line()),
2453        Format::Yaml => {
2454            format!(
2455                "{}{}",
2456                document_start(format),
2457                render_change_machine(change, kind, YamlSink::new())
2458            )
2459        }
2460        Format::Text | Format::Tree | Format::Paths | Format::Long => {
2461            unreachable!("text returned above")
2462        }
2463    }
2464}
2465
2466#[cfg(feature = "watch")]
2467fn render_change_machine(
2468    change: &crate::Change,
2469    kind: &str,
2470    mut sink: impl Sink<Output = String>,
2471) -> String {
2472    sink.event(Event::BeginMap(Shape::Block));
2473    emit_str_field(&mut sink, "schema", STREAM_SCHEMA);
2474    emit_str_field(&mut sink, "record", "change");
2475    emit_str_field(&mut sink, "op", kind);
2476    emit_path_fields(&mut sink, &change.path);
2477    emit_u64_field(&mut sink, "clock", change.clock);
2478    emit_field(&mut sink, Field::when_set("kind"), change.entry_kind.is_some(), |sink| {
2479        emit_scalar(sink, Scalar::Str(kind_label(change.entry_kind.expect("presence checked"))));
2480    });
2481    emit_field(&mut sink, Field::when_set("bytes"), change.bytes.is_some(), |sink| {
2482        emit_scalar(sink, Scalar::U64(change.bytes.expect("presence checked")));
2483    });
2484    emit_field(&mut sink, Field::when_set("allocated"), change.allocated.is_some(), |sink| {
2485        emit_scalar(sink, Scalar::U64(change.allocated.expect("presence checked")));
2486    });
2487    emit_field(&mut sink, Field::when_set("mtime_ns"), change.mtime_ns.is_some(), |sink| {
2488        emit_scalar(sink, Scalar::I64(change.mtime_ns.expect("presence checked")));
2489    });
2490    emit_field(&mut sink, Field::when_set("ignored"), change.ignored.is_some(), |sink| {
2491        emit_scalar(sink, Scalar::Bool(change.ignored.expect("presence checked")));
2492    });
2493    sink.event(Event::EndMap);
2494    sink.finish()
2495}
2496
2497/// Lossless identity for a path that does not render as UTF-8.
2498///
2499/// `to_string_lossy` replaces undecodable bytes with U+FFFD, so a consumer reading only
2500/// `root` cannot tell two different names apart. Machine output therefore carries the
2501/// native bytes alongside, and only when they are actually needed.
2502fn raw_os_identity(value: &std::ffi::OsStr) -> Option<(&'static str, String)> {
2503    if value.to_str().is_some() {
2504        return None;
2505    }
2506
2507    #[cfg(unix)]
2508    {
2509        use std::os::unix::ffi::OsStrExt;
2510
2511        Some(("unix-bytes", hex_bytes(value.as_bytes().iter().copied())))
2512    }
2513
2514    #[cfg(windows)]
2515    {
2516        use std::os::windows::ffi::OsStrExt;
2517
2518        Some(("windows-wtf16le", hex_bytes(value.encode_wide().flat_map(u16::to_le_bytes))))
2519    }
2520
2521    #[cfg(not(any(unix, windows)))]
2522    {
2523        None
2524    }
2525}
2526
2527/// Hex-encode bytes for the raw identity field.
2528#[cfg(any(unix, windows))]
2529fn hex_bytes(bytes: impl IntoIterator<Item = u8>) -> String {
2530    let mut out = String::new();
2531    for byte in bytes {
2532        let _ = write!(out, "{byte:02x}");
2533    }
2534    out
2535}
2536
2537/// Render cache status in any format.
2538///
2539/// A separate entry point rather than a `Report` section: cache status is a fact about
2540/// the cache directory, not about a tree, and folding it into the report schema would
2541/// make every consumer parse a variant that is empty on every normal run.
2542///
2543/// `scope` is the request the statuses answer. It decides only which command the text
2544/// names for reclaiming stale snapshots: a root's snapshot is cleared by its path, while a
2545/// stale file found by listing the directory may name no root this build can read.
2546///
2547/// Every machine format carries [`CACHE_SCHEMA`], the way every machine report carries its
2548/// own: the first field of the JSON document, an envelope line of its own ahead of the
2549/// rows in JSON Lines, and the first line of the YAML.
2550pub fn render_cache_status(
2551    statuses: &[crate::CacheStatus],
2552    scope: crate::CacheScope,
2553    format: Format,
2554) -> String {
2555    render_cache_status_with_options(statuses, scope, format, RenderOptions::default())
2556}
2557
2558/// Render cache status with the same human color roles as report rows.
2559/// Machine formats ignore the presentation options; cache text has no usage bar.
2560pub fn render_cache_status_with_options(
2561    statuses: &[crate::CacheStatus],
2562    scope: crate::CacheScope,
2563    format: Format,
2564    options: RenderOptions,
2565) -> String {
2566    match format {
2567        Format::Jsonl => {
2568            let mut sink = JsonSink::line();
2569            sink.event(Event::BeginMap(Shape::Inline));
2570            emit_str_field(&mut sink, "schema", CACHE_SCHEMA);
2571            sink.event(Event::EndMap);
2572            let mut out = sink.finish();
2573            for status in statuses {
2574                out.push('\n');
2575                let row = cache_row(status);
2576                let mut sink = JsonSink::line();
2577                emit_cache_field(&mut sink, &row);
2578                out.push_str(&sink.finish());
2579            }
2580            out
2581        }
2582        Format::Yaml => render_cache_machine(statuses, YamlSink::new()),
2583        // The human layout lives here beside every other human layout. It used to live in
2584        // the CLI, which meant the only way to print cache status the way fdu prints it
2585        // was to be the CLI: the Python API returned CacheStatus values nothing could
2586        // render, so the parity shim printed repr() and nine sessions differed (fdu-1kw3).
2587        Format::Text | Format::Tree | Format::Paths | Format::Long => {
2588            render_cache_status_text(statuses, scope, options.color)
2589        }
2590        Format::Json => render_cache_machine(statuses, JsonSink::pretty()),
2591    }
2592}
2593
2594fn render_cache_machine(
2595    statuses: &[crate::CacheStatus],
2596    mut sink: impl Sink<Output = String>,
2597) -> String {
2598    sink.event(Event::BeginMap(Shape::Block));
2599    emit_str_field(&mut sink, "schema", CACHE_SCHEMA);
2600    emit_field(&mut sink, Field::always("caches"), true, |sink| {
2601        sink.event(Event::BeginSeq(Shape::Block));
2602        for status in statuses {
2603            let row = cache_row(status);
2604            emit_cache_field(sink, &row);
2605        }
2606        sink.event(Event::EndSeq);
2607    });
2608    sink.event(Event::EndMap);
2609    sink.finish()
2610}
2611
2612fn emit_cache_field(sink: &mut impl Sink, field: &CacheField) {
2613    match field {
2614        CacheField::Null => emit_scalar(sink, Scalar::Null),
2615        CacheField::Bool(value) => emit_scalar(sink, Scalar::Bool(*value)),
2616        CacheField::Count(value) => emit_scalar(sink, Scalar::U64(*value)),
2617        CacheField::Text(value) => emit_scalar(sink, Scalar::Str(value)),
2618        CacheField::List(values) => {
2619            sink.event(Event::BeginSeq(Shape::Block));
2620            for value in values {
2621                emit_cache_field(sink, value);
2622            }
2623            sink.event(Event::EndSeq);
2624        }
2625        CacheField::Map(fields) => {
2626            sink.event(Event::BeginMap(Shape::Block));
2627            for (name, value) in fields {
2628                sink.event(Event::Key(name));
2629                emit_cache_field(sink, value);
2630            }
2631            sink.event(Event::EndMap);
2632        }
2633    }
2634}
2635
2636/// One value in a cache-status row.
2637///
2638/// The row is built once as fields and serialized by JSON and YAML alike, so the two
2639/// formats cannot disagree about which keys a row has or how an identity nests.
2640enum CacheField {
2641    Null,
2642    Bool(bool),
2643    Count(u64),
2644    Text(String),
2645    List(Vec<CacheField>),
2646    Map(Vec<(&'static str, CacheField)>),
2647}
2648
2649impl CacheField {
2650    fn count(value: Option<u64>) -> Self {
2651        value.map_or(Self::Null, Self::Count)
2652    }
2653}
2654
2655/// One cache-status row as fields: what every row carries, what its state adds, and the
2656/// content sidecar beside it.
2657fn cache_row(status: &crate::CacheStatus) -> CacheField {
2658    use crate::CacheState;
2659
2660    let mut fields = vec![("path", CacheField::Text(status.path.to_string_lossy().into_owned()))];
2661    if let Some((encoding, hex)) = raw_os_identity(status.path.as_os_str()) {
2662        fields.push((
2663            "path_raw",
2664            CacheField::Map(vec![
2665                ("encoding", CacheField::Text(encoding.to_string())),
2666                ("hex", CacheField::Text(hex)),
2667            ]),
2668        ));
2669    }
2670    fields.extend([
2671        ("bytes", CacheField::Count(status.bytes)),
2672        ("state", CacheField::Text(status.state.label().to_string())),
2673    ]);
2674    match &status.state {
2675        CacheState::Current(info) => {
2676            fields.push(("root", CacheField::Text(info.root.to_string_lossy().into_owned())));
2677            if let Some((encoding, hex)) = raw_os_identity(info.root.as_os_str()) {
2678                fields.push((
2679                    "root_raw",
2680                    CacheField::Map(vec![
2681                        ("encoding", CacheField::Text(encoding.to_string())),
2682                        ("hex", CacheField::Text(hex)),
2683                    ]),
2684                ));
2685            }
2686            fields.push(("entries", CacheField::Count(info.entries)));
2687            fields.push(("identity", snapshot_identity_field(info.identity)));
2688        }
2689        CacheState::Stale(reason) => fields.extend(stale_fields(*reason)),
2690        CacheState::Leftover(kind) => {
2691            fields.push(("leftover_kind", CacheField::Text(kind.label().to_string())));
2692        }
2693        CacheState::Unrecognized | CacheState::Absent => {}
2694    }
2695    // Every row carries it, whatever the state, so a consumer reads one shape rather than
2696    // discovering which keys this row happens to have.
2697    fields.push(("content", status.content.as_ref().map_or(CacheField::Null, content_field)));
2698    CacheField::Map(fields)
2699}
2700
2701/// Why a store is stale, and the format version when that is the reason.
2702fn stale_fields(reason: crate::StaleReason) -> [(&'static str, CacheField); 2] {
2703    [
2704        ("stale_reason", CacheField::Text(reason.label().to_string())),
2705        ("format_version", CacheField::count(reason.format_version().map(u64::from))),
2706    ]
2707}
2708
2709/// The content sidecar beside a snapshot: its size and state, and a current one's identity
2710/// and record count.
2711fn content_field(content: &crate::ContentStatus) -> CacheField {
2712    use crate::ContentState;
2713
2714    let mut fields = vec![
2715        ("bytes", CacheField::Count(content.bytes)),
2716        ("state", CacheField::Text(content.state.label().to_string())),
2717    ];
2718    match &content.state {
2719        ContentState::Current(info) => {
2720            fields.push(("records", CacheField::Count(info.records)));
2721            fields.push(("identity", content_identity_field(&info.identity)));
2722        }
2723        ContentState::Stale(reason) => fields.extend(stale_fields(*reason)),
2724    }
2725    CacheField::Map(fields)
2726}
2727
2728/// A snapshot's tier identities: its entry tier, and its `.gitignore` control tier as the
2729/// report's `ignore_rules` names it, `null` when no rule was read.
2730fn snapshot_identity_field(identity: crate::SnapshotIdentity) -> CacheField {
2731    let ignore_rules = match identity.controls {
2732        crate::ControlTierIdentity::NotObserved => CacheField::Null,
2733        crate::ControlTierIdentity::Observed { limits } => {
2734            let limit = |limit: Option<usize>| {
2735                CacheField::count(limit.map(|limit| u64::try_from(limit).unwrap_or(u64::MAX)))
2736            };
2737            CacheField::Map(vec![(
2738                "limits",
2739                CacheField::Map(vec![
2740                    ("budget", limit(limits.budget)),
2741                    ("line_limit", limit(limits.line_limit)),
2742                ]),
2743            )])
2744        }
2745    };
2746    CacheField::Map(vec![
2747        ("entries", entry_identity_field(identity.entries)),
2748        ("ignore_rules", ignore_rules),
2749    ])
2750}
2751
2752/// An entry tier's identity: the engine that built it, the scope fields, and the type-rules
2753/// and reducer-set fingerprints.
2754fn entry_identity_field(identity: crate::EntryTierIdentity) -> CacheField {
2755    let scope = identity.scope;
2756    let depth = scope.max_depth.map(|depth| u64::try_from(depth).unwrap_or(u64::MAX));
2757    CacheField::Map(vec![
2758        ("engine", CacheField::Count(identity.engine)),
2759        ("max_depth", CacheField::count(depth)),
2760        ("follow_symlinks", CacheField::Bool(scope.follow_symlinks)),
2761        ("one_filesystem", CacheField::Bool(scope.one_filesystem)),
2762        ("hidden_fingerprint", CacheField::Count(scope.hidden_fingerprint)),
2763        ("exclude_special", CacheField::Bool(scope.exclude_special)),
2764        ("population", CacheField::Text(scope.population.label().to_string())),
2765        ("control_fingerprint", CacheField::Count(scope.control_fingerprint)),
2766        ("type_rules_fingerprint", CacheField::Count(identity.type_rules_fingerprint)),
2767        ("reducers_fingerprint", CacheField::Count(identity.reducers_fingerprint)),
2768    ])
2769}
2770
2771/// A content tier's identity: the entry tier it was analyzed over, which holds its type
2772/// rules, then the analyzer set, options, and analyzers under the names a report's
2773/// `analysis` object gives them.
2774fn content_identity_field(identity: &crate::ContentTierIdentity) -> CacheField {
2775    let analyze = analysis_set_labels(identity.analysis)
2776        .into_iter()
2777        .map(|label| CacheField::Text(label.to_string()))
2778        .collect();
2779    let analyzers = identity
2780        .provenance
2781        .analyzers
2782        .iter()
2783        .map(|(id, version)| {
2784            CacheField::Map(vec![
2785                ("id", CacheField::Text(id.0.to_string())),
2786                ("version", CacheField::Count(u64::from(version.0))),
2787            ])
2788        })
2789        .collect();
2790    CacheField::Map(vec![
2791        ("entries", entry_identity_field(identity.entries)),
2792        ("analyze", CacheField::List(analyze)),
2793        ("options_fingerprint", CacheField::Count(identity.provenance.options_fingerprint.0)),
2794        ("analyzers", CacheField::List(analyzers)),
2795    ])
2796}
2797
2798/// The human cache-status layout: one line per file, then what can be done about the
2799/// files this build cannot use.
2800fn render_cache_status_text(
2801    statuses: &[crate::CacheStatus],
2802    scope: crate::CacheScope,
2803    color: bool,
2804) -> String {
2805    use crate::{CacheScope, CacheState, LeftoverKind, StaleReason};
2806
2807    let mut lines = Vec::new();
2808    let mut current = 0_usize;
2809    let (mut stale, mut stale_bytes) = (0_usize, 0_u64);
2810    let (mut leftover, mut leftover_bytes, mut staging) = (0_usize, 0_u64, 0_usize);
2811    let (mut unrecognized, mut unrecognized_bytes) = (0_usize, 0_u64);
2812    for status in statuses {
2813        let content_bytes = status.content_bytes().unwrap_or(0);
2814        match &status.state {
2815            CacheState::Current(info) => {
2816                current += 1;
2817                // A sidecar this build cannot serve is named, so the bytes are not read as a
2818                // usable content cache.
2819                let stale_content = status
2820                    .content
2821                    .as_ref()
2822                    .is_some_and(|content| matches!(content.state, crate::ContentState::Stale(_)));
2823                lines.push(format!(
2824                    "{}  {} entries, {} metadata, {} {}content  {}",
2825                    status.path.display(),
2826                    human_count(info.entries),
2827                    styled_bytes(status.bytes, 0, color, false),
2828                    styled_bytes(content_bytes, 0, color, false),
2829                    if stale_content { "stale " } else { "" },
2830                    info.root.display()
2831                ));
2832            }
2833            CacheState::Stale(reason) => {
2834                stale += 1;
2835                stale_bytes =
2836                    stale_bytes.saturating_add(status.bytes).saturating_add(content_bytes);
2837                let why = match reason {
2838                    StaleReason::OlderFormat { version } => {
2839                        format!("older snapshot format {version}")
2840                    }
2841                    StaleReason::NewerFormat { version } => {
2842                        format!("newer snapshot format {version}")
2843                    }
2844                    StaleReason::OtherEngine => "written by another fdu version".to_string(),
2845                    StaleReason::Unreadable => "unreadable by this build".to_string(),
2846                };
2847                lines.push(format!(
2848                    "{}  stale {}, {} metadata, {} content",
2849                    status.path.display(),
2850                    detail(&format!("({why})"), color),
2851                    styled_bytes(status.bytes, 0, color, false),
2852                    styled_bytes(content_bytes, 0, color, false)
2853                ));
2854            }
2855            CacheState::Leftover(kind) => {
2856                leftover += 1;
2857                leftover_bytes = leftover_bytes.saturating_add(status.bytes);
2858                let what = match kind {
2859                    LeftoverKind::StagingTemporary => {
2860                        staging += 1;
2861                        "staging temporary"
2862                    }
2863                    LeftoverKind::OrphanedContent => "orphaned content sidecar",
2864                };
2865                lines.push(format!(
2866                    "{}  leftover {}, {}",
2867                    status.path.display(),
2868                    detail(&format!("({what})"), color),
2869                    styled_bytes(status.bytes, 0, color, false)
2870                ));
2871            }
2872            CacheState::Unrecognized => {
2873                unrecognized += 1;
2874                unrecognized_bytes = unrecognized_bytes.saturating_add(status.bytes);
2875                lines.push(format!(
2876                    "{}  unrecognized, {}",
2877                    status.path.display(),
2878                    styled_bytes(status.bytes, 0, color, false)
2879                ));
2880            }
2881            // Root scope synthesises a status for the path a snapshot *would* occupy, so a
2882            // tree that has never been cached yields one absent entry. Absence is not a
2883            // file to describe.
2884            CacheState::Absent => {}
2885        }
2886    }
2887    if lines.is_empty() {
2888        return "No cached snapshots.".to_string();
2889    }
2890
2891    if stale > 0 {
2892        let (subject, object) = if stale == 1 {
2893            ("1 stale snapshot".to_string(), "it")
2894        } else {
2895            (format!("{} stale snapshots", human_count_u128(stale as u128)), "them")
2896        };
2897        let remedy = match scope {
2898            CacheScope::Root => format!("fdu --cache-clear PATH removes {object}"),
2899            CacheScope::All if current == 0 => format!("fdu --cache-clear=all removes {object}"),
2900            CacheScope::All => {
2901                format!("fdu --cache-clear=all removes {object}, along with every current snapshot")
2902            }
2903        };
2904        lines.push(format!(
2905            "{subject} {} cannot be served by this build; {remedy}.",
2906            cache_size_detail(stale_bytes, color)
2907        ));
2908    }
2909    if leftover > 0 {
2910        // Named as fdu's own, because they are: calling them foreign would tell the user
2911        // to leave fdu's debris alone. `=all` is the scope that reclaims them; a root's
2912        // clear reaches only the one path that root's snapshot occupies.
2913        let (subject, predicate, object) = if leftover == 1 {
2914            ("1 leftover file".to_string(), "is", "it")
2915        } else {
2916            (format!("{} leftover files", human_count_u128(leftover as u128)), "are", "them")
2917        };
2918        // A staging file is reclaimed only once it is too old to belong to a running
2919        // writer, and a status knows no file's age, so the promise names the exception
2920        // rather than counting files the clear will then decline and explain.
2921        let caveat = if staging > 0 {
2922            ", though a staging file waits until it is too old to be a running writer's"
2923        } else {
2924            ""
2925        };
2926        lines.push(format!(
2927            "{subject} {} {predicate} fdu's own, left by an interrupted \
2928             write; fdu --cache-clear=all reclaims {object}{caveat}.",
2929            cache_size_detail(leftover_bytes, color)
2930        ));
2931    }
2932    if unrecognized > 0 {
2933        let (subject, predicate, object) = if unrecognized == 1 {
2934            ("1 unrecognized file".to_string(), "is not an fdu snapshot", "it")
2935        } else {
2936            (
2937                format!("{} unrecognized files", human_count_u128(unrecognized as u128)),
2938                "are not fdu snapshots",
2939                "them",
2940            )
2941        };
2942        lines.push(format!(
2943            "{subject} {} {predicate}, so fdu leaves {object} in place.",
2944            cache_size_detail(unrecognized_bytes, color)
2945        ));
2946    }
2947    lines.join("\n")
2948}
2949
2950fn cache_size_detail(bytes: u64, color: bool) -> String {
2951    format!("{}{}{}", detail("(", color), styled_bytes(bytes, 0, color, true), detail(")", color))
2952}
2953
2954#[cfg(test)]
2955mod tests {
2956    fn render(report: &Report, format: Format, color: bool) -> String {
2957        super::render(report, format, color).expect("compatible report format")
2958    }
2959
2960    use super::*;
2961    use crate::Index;
2962    use crate::engine_contract::{Attrs, Observation, Op, ScanScope};
2963    use crate::query::{Bound, Query, Request, Selection, ShareThreshold};
2964    use std::ffi::OsStr;
2965    use std::path::PathBuf;
2966    use std::process::{Command, Stdio};
2967    use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
2968
2969    struct Provenance {
2970        scan_started_at: Option<SystemTime>,
2971        generated_at: SystemTime,
2972        source: ReportSource,
2973        complete: bool,
2974        errors: Vec<String>,
2975    }
2976
2977    fn report(index: &Index, request: &Request, provenance: &Provenance) -> crate::Result<Report> {
2978        let mut report = crate::query::report(index, request, provenance.generated_at)?;
2979        report.provenance.scan_started_at = provenance.scan_started_at;
2980        report.provenance.source = provenance.source;
2981        report.status.complete = provenance.complete;
2982        report.status.errors = provenance
2983            .errors
2984            .iter()
2985            .cloned()
2986            .map(|message| crate::Issue::provider_failure(None, message))
2987            .collect();
2988        Ok(report)
2989    }
2990
2991    fn attrs(size: u64, mtime_ns: i64) -> Attrs {
2992        Attrs {
2993            size,
2994            allocated: size.div_ceil(512) * 512,
2995            mtime_ns,
2996            ctime_ns: mtime_ns,
2997            inode: 7,
2998            dev: 1,
2999        }
3000    }
3001
3002    fn cache_file(name: &str, bytes: u64, state: crate::CacheState) -> crate::CacheStatus {
3003        // A stale snapshot here keeps the sidecar an older format wrote beside it.
3004        let content =
3005            matches!(state, crate::CacheState::Stale(_)).then_some(crate::ContentStatus {
3006                bytes: 5,
3007                state: crate::ContentState::Stale(crate::StaleReason::OlderFormat { version: 4 }),
3008            });
3009        crate::CacheStatus { path: PathBuf::from(name), bytes, content, state }
3010    }
3011
3012    /// A snapshot identity with a small value in every field, so a rendering is readable.
3013    fn small_snapshot_identity() -> crate::SnapshotIdentity {
3014        crate::SnapshotIdentity {
3015            entries: crate::EntryTierIdentity {
3016                engine: 1,
3017                scope: crate::EntryScope {
3018                    max_depth: None,
3019                    follow_symlinks: false,
3020                    one_filesystem: true,
3021                    hidden_fingerprint: 2,
3022                    exclude_special: false,
3023                    population: IgnoredEntries::Include,
3024                    control_fingerprint: 0,
3025                },
3026                type_rules_fingerprint: 3,
3027                reducers_fingerprint: 4,
3028            },
3029            controls: crate::ControlTierIdentity::Observed {
3030                limits: crate::control::ControlLimits { budget: Some(10), line_limit: None },
3031            },
3032        }
3033    }
3034
3035    /// Stale, leftover, and unrecognized files are shown, sized, and followed by what
3036    /// reclaims them.
3037    ///
3038    /// A unit test beside the goldens because a golden cannot produce a newer format or
3039    /// every reason at once, and because the remedy depends on the scope and on whether
3040    /// clearing would also take current snapshots.
3041    #[test]
3042    fn cache_status_shows_stale_and_unrecognized_files_with_their_remedy() {
3043        use crate::{CacheScope, CacheState, LeftoverKind, StaleReason};
3044
3045        let stale = [
3046            cache_file("a.fdu", 10, CacheState::Stale(StaleReason::OlderFormat { version: 2 })),
3047            cache_file("b.fdu", 20, CacheState::Stale(StaleReason::NewerFormat { version: 99 })),
3048            cache_file("c.fdu", 30, CacheState::Stale(StaleReason::OtherEngine)),
3049            cache_file("d.fdu", 40, CacheState::Stale(StaleReason::Unreadable)),
3050            cache_file("notes.txt", 14, CacheState::Unrecognized),
3051        ];
3052        assert_eq!(
3053            render_cache_status(&stale, CacheScope::All, Format::Text),
3054            "a.fdu  stale (older snapshot format 2), 10 B metadata, 5 B content\n\
3055             b.fdu  stale (newer snapshot format 99), 20 B metadata, 5 B content\n\
3056             c.fdu  stale (written by another fdu version), 30 B metadata, 5 B content\n\
3057             d.fdu  stale (unreadable by this build), 40 B metadata, 5 B content\n\
3058             notes.txt  unrecognized, 14 B\n\
3059             4 stale snapshots (120 B) cannot be served by this build; \
3060             fdu --cache-clear=all removes them.\n\
3061             1 unrecognized file (14 B) is not an fdu snapshot, so fdu leaves it in place."
3062        );
3063        let colored = render_cache_status_with_options(
3064            &stale,
3065            CacheScope::All,
3066            Format::Text,
3067            RenderOptions { color: true, ..RenderOptions::default() },
3068        );
3069        assert!(colored.contains(&detail("(older snapshot format 2)", true)), "{colored:?}");
3070        assert!(
3071            colored.contains(&format!("4 stale snapshots {} cannot", cache_size_detail(120, true))),
3072            "{colored:?}"
3073        );
3074        assert_eq!(
3075            strip_ansi(&colored),
3076            render_cache_status(&stale, CacheScope::All, Format::Text)
3077        );
3078
3079        // fdu's own debris is named as fdu's, so a reader is not told to leave it alone.
3080        let leftovers = [
3081            cache_file(
3082                ".g.fdu.tmp.1.2.3",
3083                60,
3084                CacheState::Leftover(LeftoverKind::StagingTemporary),
3085            ),
3086            cache_file("h.analysis.bin", 70, CacheState::Leftover(LeftoverKind::OrphanedContent)),
3087        ];
3088        assert_eq!(
3089            render_cache_status(&leftovers, CacheScope::All, Format::Text),
3090            ".g.fdu.tmp.1.2.3  leftover (staging temporary), 60 B\n\
3091             h.analysis.bin  leftover (orphaned content sidecar), 70 B\n\
3092             2 leftover files (130 B) are fdu's own, left by an interrupted write; \
3093             fdu --cache-clear=all reclaims them, though a staging file waits until it is \
3094             too old to be a running writer's."
3095        );
3096        assert!(render_cache_status(&leftovers[..1], CacheScope::Root, Format::Text).ends_with(
3097            "1 leftover file (60 B) is fdu's own, left by an interrupted write; \
3098                 fdu --cache-clear=all reclaims it, though a staging file waits until it \
3099                 is too old to be a running writer's."
3100        ));
3101        // With no staging file listed, nothing is held back and the promise is plain: a
3102        // status that named an exception with no file it could apply to would be noise.
3103        assert!(render_cache_status(&leftovers[1..], CacheScope::All, Format::Text).ends_with(
3104            "1 leftover file (70 B) is fdu's own, left by an interrupted write; \
3105                 fdu --cache-clear=all reclaims it."
3106        ));
3107
3108        let root = [cache_file("a.fdu", 10, CacheState::Stale(StaleReason::OtherEngine))];
3109        assert!(render_cache_status(&root, CacheScope::Root, Format::Text).ends_with(
3110            "1 stale snapshot (15 B) cannot be served by this build; \
3111                 fdu --cache-clear PATH removes it."
3112        ));
3113
3114        let current = cache_file(
3115            "e.fdu",
3116            50,
3117            CacheState::Current(crate::SnapshotInfo {
3118                root: PathBuf::from("/tree"),
3119                identity: small_snapshot_identity(),
3120                entries: 3,
3121            }),
3122        );
3123        let mixed = [stale[0].clone(), current, stale[4].clone(), stale[4].clone()];
3124        assert!(render_cache_status(&mixed, CacheScope::All, Format::Text).ends_with(
3125            "e.fdu  3 entries, 50 B metadata, 0 B content  /tree\n\
3126             notes.txt  unrecognized, 14 B\n\
3127             notes.txt  unrecognized, 14 B\n\
3128             1 stale snapshot (15 B) cannot be served by this build; \
3129             fdu --cache-clear=all removes it, along with every current snapshot.\n\
3130             2 unrecognized files (28 B) are not fdu snapshots, so fdu leaves them in place."
3131        ));
3132
3133        let absent = [cache_file("f.fdu", 0, CacheState::Absent)];
3134        assert_eq!(
3135            render_cache_status(&absent, CacheScope::Root, Format::Text),
3136            "No cached snapshots."
3137        );
3138        // Every row carries the same keys whatever its state, and the envelope line
3139        // carries the schema even when nothing follows it.
3140        assert_eq!(
3141            render_cache_status(
3142                &[stale[0].clone(), absent[0].clone(), stale[4].clone(), leftovers[0].clone()],
3143                CacheScope::All,
3144                Format::Jsonl
3145            ),
3146            "{\"schema\": \"fdu.cache/3\"}\n\
3147             {\"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\
3148             {\"path\": \"f.fdu\", \"bytes\": 0, \"state\": \"absent\", \"content\": null}\n\
3149             {\"path\": \"notes.txt\", \"bytes\": 14, \"state\": \"unrecognized\", \"content\": null}\n\
3150             {\"path\": \".g.fdu.tmp.1.2.3\", \"bytes\": 60, \"state\": \"leftover\", \"leftover_kind\": \"staging_temporary\", \"content\": null}"
3151        );
3152        assert_eq!(
3153            render_cache_status(&[], CacheScope::All, Format::Jsonl),
3154            "{\"schema\": \"fdu.cache/3\"}"
3155        );
3156        assert_eq!(
3157            render_cache_status(&[], CacheScope::All, Format::Json),
3158            "{\n  \"schema\": \"fdu.cache/3\",\n  \"caches\": []\n}\n"
3159        );
3160        // An empty sequence in both formats: a bare `caches:` is YAML null, and a reader
3161        // of one schema should not have to tell null from a list it can iterate.
3162        assert_eq!(
3163            render_cache_status(&[], CacheScope::All, Format::Yaml),
3164            "schema: fdu.cache/3\ncaches: []\n"
3165        );
3166        assert!(
3167            render_cache_status(&stale[2..3], CacheScope::All, Format::Json)
3168                .starts_with("{\n  \"schema\": \"fdu.cache/3\",\n  \"caches\": [\n    {")
3169        );
3170        assert!(
3171            render_cache_status(&stale[2..3], CacheScope::All, Format::Yaml)
3172                .starts_with("schema: fdu.cache/3\ncaches:\n  -\n    path: c.fdu")
3173        );
3174        assert!(
3175            render_cache_status(&stale[2..3], CacheScope::All, Format::Yaml).ends_with(
3176                "state: stale\n    stale_reason: other_engine\n    format_version: null\n    \
3177                 content:\n      bytes: 5\n      state: stale\n      stale_reason: older_format\n      \
3178                 format_version: 4\n"
3179            )
3180        );
3181        assert!(render_cache_status(&leftovers[1..], CacheScope::All, Format::Yaml).ends_with(
3182            "state: leftover\n    leftover_kind: orphaned_content\n    content: null\n"
3183        ));
3184    }
3185
3186    /// A current snapshot carries the identity of every tier it holds, and the sidecar
3187    /// beside it its own, nested the same way in JSON and YAML: the entry tier, then the
3188    /// control tier as the report's `ignore_rules` names it, and for content its entry tier,
3189    /// which alone holds the type rules, then the analyzer set, options, and analyzers under
3190    /// the names a report's `analysis` object gives them.
3191    #[test]
3192    fn cache_status_carries_every_stored_tier_identity() {
3193        use crate::{CacheScope, CacheState, ContentInfo, ContentState, ContentStatus};
3194
3195        let snapshot = small_snapshot_identity();
3196        let content = crate::ContentTierIdentity {
3197            entries: snapshot.entries,
3198            analysis: crate::content::AnalysisSet::NONE.with_lines(),
3199            provenance: crate::AnalyzerProvenance {
3200                options_fingerprint: crate::content::OptionsFingerprint(5),
3201                analyzers: vec![(
3202                    crate::content::CONTENT_BASIC,
3203                    crate::content::AnalyzerVersion(1),
3204                )],
3205            },
3206        };
3207        let status = crate::CacheStatus {
3208            path: PathBuf::from("e.fdu"),
3209            bytes: 50,
3210            content: Some(ContentStatus {
3211                bytes: 9,
3212                state: ContentState::Current(ContentInfo { identity: content, records: 2 }),
3213            }),
3214            state: CacheState::Current(crate::SnapshotInfo {
3215                root: PathBuf::from("/tree"),
3216                identity: snapshot,
3217                entries: 3,
3218            }),
3219        };
3220        let entries = "{\"engine\": 1, \"max_depth\": null, \"follow_symlinks\": false, \
3221                       \"one_filesystem\": true, \"hidden_fingerprint\": 2, \"exclude_special\": false, \
3222                       \"population\": \"include\", \"control_fingerprint\": 0, \
3223                       \"type_rules_fingerprint\": 3, \"reducers_fingerprint\": 4}";
3224        assert_eq!(
3225            render_cache_status(std::slice::from_ref(&status), CacheScope::Root, Format::Jsonl),
3226            format!(
3227                "{{\"schema\": \"fdu.cache/3\"}}\n\
3228                 {{\"path\": \"e.fdu\", \"bytes\": 50, \"state\": \"current\", \"root\": \"/tree\", \
3229                 \"entries\": 3, \"identity\": {{\"entries\": {entries}, \"ignore_rules\": \
3230                 {{\"limits\": {{\"budget\": 10, \"line_limit\": null}}}}}}, \"content\": {{\"bytes\": 9, \
3231                 \"state\": \"current\", \"records\": 2, \"identity\": {{\"entries\": {entries}, \
3232                 \"analyze\": [\"lines\"], \"options_fingerprint\": 5, \
3233                 \"analyzers\": [{{\"id\": \"content-basic-v1\", \"version\": 1}}]}}}}}}"
3234            )
3235        );
3236        let entries = "\n          engine: 1\n          max_depth: null\n          \
3237                       follow_symlinks: false\n          one_filesystem: true\n          \
3238                       hidden_fingerprint: 2\n          exclude_special: false\n          \
3239                       population: include\n          control_fingerprint: 0\n          \
3240                       type_rules_fingerprint: 3\n          reducers_fingerprint: 4";
3241        assert_eq!(
3242            render_cache_status(std::slice::from_ref(&status), CacheScope::Root, Format::Yaml),
3243            format!(
3244                "schema: fdu.cache/3\ncaches:\n  -\n    path: e.fdu\n    bytes: 50\n    state: current\n    \
3245                 root: /tree\n    entries: 3\n    identity:\n      entries:{}\n      \
3246                 ignore_rules:\n        limits:\n          budget: 10\n          line_limit: null\n    \
3247                 content:\n      bytes: 9\n      state: current\n      records: 2\n      identity:\n        \
3248                 entries:{entries}\n        analyze:\n          - lines\n        \
3249                 options_fingerprint: 5\n        analyzers:\n          -\n            id: content-basic-v1\n            \
3250                 version: 1\n",
3251                entries.replace("\n  ", "\n")
3252            )
3253        );
3254        let mut large = status.clone();
3255        large.bytes = 60_696_111;
3256        large.content.as_mut().expect("fixture has a content sidecar").bytes = 120_783_062;
3257        assert_eq!(
3258            render_cache_status(std::slice::from_ref(&large), CacheScope::Root, Format::Text),
3259            "e.fdu  3 entries, 57 MiB metadata, 115 MiB content  /tree"
3260        );
3261        let colored = render_cache_status_with_options(
3262            std::slice::from_ref(&large),
3263            CacheScope::Root,
3264            Format::Text,
3265            RenderOptions { color: true, ..RenderOptions::default() },
3266        );
3267        assert_eq!(
3268            strip_ansi(&colored),
3269            render_cache_status(&[large], CacheScope::Root, Format::Text)
3270        );
3271        let zero = render_cache_status_with_options(
3272            std::slice::from_ref(&status),
3273            CacheScope::Root,
3274            Format::Text,
3275            RenderOptions { color: true, ..RenderOptions::default() },
3276        );
3277        assert!(!zero.contains("\x1b["), "positive sub-GiB sizes stay unstyled: {zero:?}");
3278        let mut zero_and_gib = status.clone();
3279        zero_and_gib.bytes = 0;
3280        zero_and_gib.content.as_mut().expect("fixture has a content sidecar").bytes = 1 << 30;
3281        let colored = render_cache_status_with_options(
3282            std::slice::from_ref(&zero_and_gib),
3283            CacheScope::Root,
3284            Format::Text,
3285            RenderOptions { color: true, ..RenderOptions::default() },
3286        );
3287        assert!(colored.contains(&paint("0 B", STYLE_DETAIL, true)), "{colored:?}");
3288        assert!(colored.contains(&paint("1.0 GiB", AnsiStyle::new().bold(), true)), "{colored:?}");
3289        assert_eq!(strip_ansi(&colored), "e.fdu  3 entries, 0 B metadata, 1.0 GiB content  /tree");
3290        for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3291            assert_eq!(
3292                render_cache_status_with_options(
3293                    std::slice::from_ref(&status),
3294                    CacheScope::Root,
3295                    format,
3296                    RenderOptions { color: true, ..RenderOptions::default() },
3297                ),
3298                render_cache_status(std::slice::from_ref(&status), CacheScope::Root, format)
3299            );
3300        }
3301        // A sidecar this build cannot serve is named in text too.
3302        let stale_content = crate::CacheStatus {
3303            content: Some(ContentStatus {
3304                bytes: 9,
3305                state: ContentState::Stale(crate::StaleReason::OtherEngine),
3306            }),
3307            ..status
3308        };
3309        assert_eq!(
3310            render_cache_status(&[stale_content], CacheScope::Root, Format::Text),
3311            "e.fdu  3 entries, 50 B metadata, 9 B stale content  /tree"
3312        );
3313    }
3314
3315    /// The cache schema is a promise, like the report schema beside it.
3316    ///
3317    /// Fails loudly when the string moves, so the field rename this constant was added
3318    /// for — `recognized` to `state` — cannot happen again without a version to key on.
3319    #[test]
3320    fn the_cache_schema_constant_is_the_versioning_promise() {
3321        assert_eq!(CACHE_SCHEMA, "fdu.cache/3");
3322        for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3323            let rendered = render_cache_status(&[], crate::CacheScope::All, format);
3324            assert!(rendered.contains(CACHE_SCHEMA), "{format:?} carries no schema: {rendered}");
3325        }
3326    }
3327
3328    #[cfg(all(unix, feature = "watch"))]
3329    #[test]
3330    fn cache_and_change_rows_preserve_non_unicode_raw_paths() {
3331        use std::ffi::OsString;
3332        use std::os::unix::ffi::OsStringExt;
3333
3334        let path = PathBuf::from(OsString::from_vec(vec![b'n', 0x80]));
3335        let status = crate::CacheStatus {
3336            path: path.clone(),
3337            bytes: 3,
3338            content: None,
3339            state: crate::CacheState::Unrecognized,
3340        };
3341        for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3342            let rendered =
3343                render_cache_status(std::slice::from_ref(&status), crate::CacheScope::All, format);
3344            assert!(rendered.contains("path_raw"), "{format:?}: {rendered}");
3345            assert!(rendered.contains("6e80"), "{format:?}: {rendered}");
3346        }
3347
3348        let change = crate::Change {
3349            path,
3350            kind: crate::ChangeKind::Remove,
3351            entry_kind: None,
3352            bytes: None,
3353            allocated: None,
3354            mtime_ns: None,
3355            ignored: None,
3356            clock: 1,
3357        };
3358        for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3359            let rendered = render_change(&change, format);
3360            assert!(rendered.contains("path_raw"), "{format:?}: {rendered}");
3361            assert!(rendered.contains("6e80"), "{format:?}: {rendered}");
3362        }
3363    }
3364
3365    /// A bound states itself, and the count it states is the count it dropped.
3366    ///
3367    /// The second half is why this is a unit test: a golden fixture is too small for a
3368    /// wrong total to look wrong, and the defect being guarded against — twenty rows of
3369    /// 192,871 presented as everything — only shows at a scale goldens do not reach.
3370    #[test]
3371    fn a_bound_states_itself_and_states_it_accurately() {
3372        let report = fixture(&[ViewSpec::Files]);
3373        let Section::Files { rows, total, .. } = &report.sections[0] else {
3374            panic!("expected a files section");
3375        };
3376        let full = rows.len();
3377        assert_eq!(*total, full, "an unbounded view drops nothing");
3378        assert!(!render(&report, Format::Text, false).contains("--limit all"), "and says nothing");
3379        assert!(render(&report, Format::Json, false).contains("\"bound\": null"));
3380
3381        // Now bound it to one row and check the report agrees with reality.
3382        let mut query = Query { views: vec![ViewSpec::Files], ..Query::default() };
3383        query.selection.limit = Some(Bound::Limit(1));
3384        let bounded = fixture_for(&query);
3385        let Section::Files { rows, total, .. } = &bounded.sections[0] else {
3386            panic!("expected a files section");
3387        };
3388        assert_eq!(rows.len(), 1);
3389        assert_eq!(*total, full, "the total is what there was, not what was kept");
3390
3391        let text = render(&bounded, Format::Text, false);
3392        assert!(text.contains(&format!("(1 of {full}")), "the header states the bound: {text}");
3393        assert!(!text.contains("--limit"), "actionable guidance stays out of the body: {text}");
3394        assert_eq!(
3395            report_tips(&bounded),
3396            vec!["tip: show more rows: limit=all"],
3397            "the shared epilogue names the row remedy once"
3398        );
3399        let json = render(&bounded, Format::Json, false);
3400        assert!(json.contains(&format!("\"shown\": 1, \"total\": {full}")), "{json:.200}");
3401        let yaml = render(&bounded, Format::Yaml, false);
3402        assert!(yaml.contains("shown: 1"), "{yaml:.200}");
3403    }
3404
3405    /// Colour must not move anything.
3406    ///
3407    /// The golden suite structurally cannot check this: it runs under `NO_COLOR=1` and
3408    /// only ever sees the uncoloured form, which is exactly how the extensions view
3409    /// shipped misaligned — `{:<12}` counted the escape sequences toward the field width,
3410    /// so the padding collapsed the moment colour was on and every golden still passed.
3411    fn strip_ansi(text: &str) -> String {
3412        let mut out = String::with_capacity(text.len());
3413        let mut chars = text.chars();
3414        while let Some(c) = chars.next() {
3415            if c == '\u{1b}' {
3416                for escape in chars.by_ref() {
3417                    if escape.is_ascii_alphabetic() {
3418                        break;
3419                    }
3420                }
3421            } else {
3422                out.push(c);
3423            }
3424        }
3425        out
3426    }
3427
3428    #[test]
3429    fn colour_never_changes_the_layout_of_any_view() {
3430        for view in [
3431            ViewSpec::Tree,
3432            ViewSpec::Extensions,
3433            ViewSpec::Types,
3434            ViewSpec::Families,
3435            ViewSpec::Languages,
3436            ViewSpec::Files,
3437            ViewSpec::Summary,
3438        ] {
3439            let report = fixture(&[view]);
3440            let plain = render(&report, Format::Text, false);
3441            let coloured = render(&report, Format::Text, true);
3442            // Files is a bare listing meant for piping; this summary has no ignored
3443            // contribution to annotate. The other fixtures have a styled label.
3444            let styles_a_label = !matches!(view, ViewSpec::Files | ViewSpec::Summary);
3445            assert_eq!(
3446                plain != coloured,
3447                styles_a_label,
3448                "{view:?} disagrees with whether it styles a label"
3449            );
3450            assert_eq!(
3451                strip_ansi(&coloured).replace('·', "░").replace('▒', "█"),
3452                plain,
3453                "{view:?} lays out differently once colour is on"
3454            );
3455        }
3456    }
3457
3458    /// The rule the helper exists to enforce, stated directly.
3459    #[test]
3460    fn a_label_cell_is_measured_on_visible_text() {
3461        let plain = label_cell("md", 6, STYLE_CATEGORY, false);
3462        let coloured = label_cell("md", 6, STYLE_CATEGORY, true);
3463        assert_eq!(plain, "md    ", "four spaces of padding");
3464        assert!(coloured.starts_with('\u{1b}'), "the label is styled");
3465        assert!(coloured.ends_with("    "), "and padded by the same four: {coloured:?}");
3466        // A label at or past the width gets no padding rather than a negative one.
3467        assert_eq!(label_cell("verylonglabel", 4, STYLE_CATEGORY, false), "verylonglabel");
3468    }
3469
3470    /// Every view, so a matrix test cannot silently skip one that was added later.
3471    const ALL_TEST_VIEWS: [ViewSpec; 11] = [
3472        ViewSpec::List,
3473        ViewSpec::Tree,
3474        ViewSpec::Types,
3475        ViewSpec::Extensions,
3476        ViewSpec::Families,
3477        ViewSpec::Languages,
3478        ViewSpec::Documents,
3479        ViewSpec::Files,
3480        ViewSpec::Largest,
3481        ViewSpec::Recent,
3482        ViewSpec::Summary,
3483    ];
3484
3485    /// Check a walk against its declaration without parsing or trusting a writer.
3486    struct SchemaCheck<S> {
3487        inner: S,
3488        expected: Vec<&'static str>,
3489        next: usize,
3490        depth: usize,
3491    }
3492
3493    impl<S: Sink> SchemaCheck<S> {
3494        fn report(inner: S, lossy: bool, sections: bool) -> Self {
3495            let fields = &REPORT_FIELDS;
3496            let ordered = [
3497                fields.schema,
3498                fields.generator,
3499                fields.root,
3500                fields.root_raw,
3501                fields.age_reference_ns,
3502                fields.request,
3503                fields.status,
3504                fields.provenance,
3505                fields.ignore_rules,
3506                fields.analysis,
3507                fields.reports,
3508            ];
3509            let expected = ordered
3510                .into_iter()
3511                .filter_map(|field| {
3512                    let present = match field.presence {
3513                        Presence::Always | Presence::Nullable => true,
3514                        Presence::WhenLossy => lossy,
3515                        Presence::WhenSet => sections,
3516                        Presence::WhenAnalyzer(_) => panic!("envelope has no analyzer-owned field"),
3517                    };
3518                    present.then_some(field.name)
3519                })
3520                .collect();
3521            Self { inner, expected, next: 0, depth: 0 }
3522        }
3523    }
3524
3525    impl<S: Sink> Sink for SchemaCheck<S> {
3526        type Output = S::Output;
3527
3528        fn event(&mut self, event: Event<'_>) {
3529            match event {
3530                Event::BeginMap(_) | Event::BeginSeq(_) => self.depth += 1,
3531                Event::EndMap | Event::EndSeq => self.depth -= 1,
3532                Event::Key(name) if self.depth == 1 => {
3533                    assert_eq!(
3534                        Some(&name),
3535                        self.expected.get(self.next),
3536                        "wire field order/presence"
3537                    );
3538                    self.next += 1;
3539                }
3540                Event::Key(_) | Event::Scalar(_) => {}
3541            }
3542            self.inner.event(event);
3543        }
3544
3545        fn finish(self) -> Self::Output {
3546            assert_eq!(self.depth, 0, "unclosed collection");
3547            assert_eq!(self.next, self.expected.len(), "required field missing");
3548            self.inner.finish()
3549        }
3550    }
3551
3552    #[test]
3553    fn report_walk_obeys_declared_field_order_and_presence_for_every_writer() {
3554        for view in [ViewSpec::Summary, ViewSpec::Documents] {
3555            let report = fixture(&[view]);
3556            for sections in [false, true] {
3557                let mut json = SchemaCheck::report(JsonSink::pretty(), false, sections);
3558                emit_report(&mut json, &report, sections);
3559                assert!(!json.finish().is_empty());
3560                let mut line = SchemaCheck::report(JsonSink::line(), false, sections);
3561                emit_report(&mut line, &report, sections);
3562                assert!(!line.finish().is_empty());
3563                let mut yaml = SchemaCheck::report(YamlSink::new(), false, sections);
3564                emit_report(&mut yaml, &report, sections);
3565                assert!(!yaml.finish().is_empty());
3566            }
3567        }
3568    }
3569
3570    #[test]
3571    #[should_panic(expected = "required field missing")]
3572    fn schema_check_rejects_a_missing_required_field() {
3573        let mut check = SchemaCheck::report(JsonSink::line(), false, true);
3574        check.event(Event::BeginMap(Shape::Inline));
3575        check.event(Event::EndMap);
3576        check.finish();
3577    }
3578
3579    #[test]
3580    fn list_formats_preserve_the_default_tree_and_expose_flat_subtree_metrics() {
3581        let legacy = fixture(&[ViewSpec::Tree]);
3582        let list = fixture(&[ViewSpec::List]);
3583        assert_eq!(
3584            super::render(&legacy, Format::Text, false).expect("tree"),
3585            super::render(&list, Format::Tree, false).expect("list tree")
3586        );
3587        assert!(
3588            super::render(&list, Format::Paths, false).is_err(),
3589            "folding cannot silently become an inventory"
3590        );
3591        let mut rejected = Vec::new();
3592        assert_eq!(
3593            write(&list, Format::Paths, false, &mut rejected)
3594                .expect_err("folded projection")
3595                .kind(),
3596            io::ErrorKind::InvalidInput
3597        );
3598        assert!(rejected.is_empty(), "validate before writing any bytes");
3599        let flat = fixture_for(&Query {
3600            views: vec![ViewSpec::List],
3601            format: Format::Paths,
3602            selection: Selection {
3603                kinds: vec![EntryKind::Dir],
3604                size: SizeMetric::Apparent,
3605                ..Selection::default()
3606            },
3607            ..Query::default()
3608        });
3609        assert_eq!(super::render(&flat, Format::Paths, false).expect("paths"), "src\n");
3610        assert!(super::render(&flat, Format::Long, false).expect("long").contains("100 B"));
3611        assert!(super::render(&flat, Format::Tree, false).is_err());
3612        let Section::Files { rows, .. } = &flat.sections[0] else { panic!("flat list") };
3613        assert_eq!((rows[0].files, rows[0].dirs, rows[0].mtime_ns), (Some(1), Some(0), 10));
3614        assert_eq!(rows[0].complete, Some(true));
3615        assert!(
3616            super::render(&flat, Format::Json, false).expect("json").contains("\"complete\": true")
3617        );
3618        assert_eq!(
3619            rows[0].age_ns,
3620            flat.age_reference_ns.map(|reference| i128::from(reference) - 10)
3621        );
3622        for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3623            let wire = super::render(&flat, format, false).expect("serialization");
3624            assert!(wire.contains("age_reference_ns"));
3625            assert!(wire.contains("age_ns"));
3626        }
3627        assert_eq!(human_age(Some(-1)), "-0s");
3628        assert_eq!(human_age(Some(30 * 86400 * 1_000_000_000)), "30d");
3629        assert_eq!(human_age(None), "unknown");
3630        assert_eq!(flat_path(Path::new("a\nb\tc")), "a\\nb\\tc");
3631        // A backslash is the Windows separator; escaping it would print a path that
3632        // does not exist, so it is written as it is on every platform.
3633        assert_eq!(flat_path(Path::new("d/a\\b")), "d/a\\b");
3634        let mut stale = flat.clone();
3635        stale.provenance.source = ReportSource::CacheOnly;
3636        stale.provenance.freshness = Freshness::Stale;
3637        stale.scope.max_depth = Some(2);
3638        let notes = flat_diagnostics(&stale).join("\n");
3639        assert!(notes.contains("warn: stale answer"), "{notes}");
3640        assert!(!notes.contains("note: cache-only"), "the warning states it once: {notes}");
3641        assert!(notes.contains("freshness: stale"));
3642        assert!(notes.contains("scan scope limited to depth 2"));
3643        assert_eq!(super::render(&stale, Format::Paths, false).expect("paths"), "src\n");
3644    }
3645
3646    /// Only an answer nothing verified carries the stale warning, it names the surface's
3647    /// own option for a fresh answer, and it sits between the notes and the tips, where a
3648    /// quiet frontend's filter keeps it (fdu-mdop).
3649    #[test]
3650    fn only_a_cache_only_answer_warns_that_it_is_stale_in_the_surface_vocabulary() {
3651        let tree = Query { views: vec![ViewSpec::Tree], ..Query::default() };
3652        let mut verified = fixture_for(&tree);
3653        for source in [ReportSource::ColdScan, ReportSource::WarmRevalidate] {
3654            verified.provenance.source = source;
3655            assert!(report_warnings(&verified).is_empty(), "{source:?} is verified");
3656            assert!(!diagnostics(&verified).iter().any(|line| line.starts_with("warn:")));
3657        }
3658        for (axes, option) in [
3659            (&crate::query::AxisNames::FLAGS, "--stale-ok"),
3660            (&crate::query::AxisNames::FIELDS, "stale_ok"),
3661        ] {
3662            let mut stale = fixture_for(&Query {
3663                axes,
3664                selection: Selection { depth: Some(Bound::Limit(0)), ..Selection::default() },
3665                ..tree.clone()
3666            });
3667            stale.provenance.source = ReportSource::CacheOnly;
3668            stale.provenance.freshness = Freshness::Stale;
3669            let warning = format!(
3670                "warn: stale answer: served from the snapshot without filesystem verification; \
3671                 drop {option} for a fresh answer"
3672            );
3673            assert_eq!(report_warnings(&stale), std::slice::from_ref(&warning));
3674            assert!(!report_notes(&stale).contains(&warning), "a note would be quieted");
3675            let lines = diagnostics(&stale);
3676            let at = lines.iter().position(|line| *line == warning).expect("warning");
3677            assert!(lines[..at].iter().all(|line| line.starts_with("note:")), "{lines:?}");
3678            assert!(lines[at + 1..].iter().all(|line| line.starts_with("tip:")), "{lines:?}");
3679            assert!(lines.first().is_some_and(|line| line.starts_with("note:")), "{lines:?}");
3680            assert!(lines.last().is_some_and(|line| line.starts_with("tip:")), "{lines:?}");
3681            let rendered = super::render(&stale, Format::Text, false).expect("text");
3682            assert!(!rendered.contains("warn:"), "stdout carries only the answer: {rendered}");
3683        }
3684    }
3685
3686    #[test]
3687    fn signed_ages_preserve_exact_endpoints_in_streaming_machine_formats() {
3688        let mut report = fixture_for(&Query {
3689            views: vec![ViewSpec::List],
3690            format: Format::Json,
3691            ..Query::default()
3692        });
3693        for (reference, modified) in [(i64::MAX, i64::MIN), (i64::MIN, i64::MAX)] {
3694            let age = i128::from(reference) - i128::from(modified);
3695            report.age_reference_ns = Some(reference);
3696            let Section::Files { rows, .. } = &mut report.sections[0] else { panic!("flat rows") };
3697            rows[0].mtime_ns = modified;
3698            rows[0].age_ns = Some(age);
3699            for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3700                let rendered = super::render(&report, format, false).expect("machine format");
3701                assert!(rendered.contains(&age.to_string()), "{format:?} lost exact signed age");
3702                let mut streamed = Vec::new();
3703                write(&report, format, false, &mut streamed).expect("streaming writer");
3704                assert_eq!(streamed, rendered.as_bytes());
3705            }
3706        }
3707    }
3708
3709    fn fixture(views: &[ViewSpec]) -> Report {
3710        fixture_for(&Query { views: views.to_vec(), ..Query::default() })
3711    }
3712
3713    fn fixture_for(query: &Query) -> Report {
3714        let mut index = Index::new_with_scope("/root", ScanScope::default());
3715        // A documents view is an answer about analyzers, so the index it is rendered from
3716        // holds one: the request model refuses that view over an index with no content tier,
3717        // whichever door the request came through. `words` and not `all`, because the code
3718        // analyzer would move the languages view's share off bytes.
3719        if query.views.contains(&ViewSpec::Documents) {
3720            index.prepare_content_analysis(crate::content::AnalysisRequest {
3721                profile: crate::content::AnalysisSet::NONE.with_words(),
3722                ..crate::content::AnalysisRequest::default()
3723            });
3724        }
3725        index
3726            .apply(&Observation::new(vec![
3727                Op::Upsert {
3728                    path: PathBuf::from("src"),
3729                    kind: EntryKind::Dir,
3730                    attrs: Attrs::default(),
3731                },
3732                Op::Upsert {
3733                    path: PathBuf::from("src/main.rs"),
3734                    kind: EntryKind::File,
3735                    attrs: attrs(100, 10),
3736                },
3737                Op::Upsert {
3738                    path: PathBuf::from("notes.md"),
3739                    kind: EntryKind::File,
3740                    attrs: attrs(20, 20),
3741                },
3742            ]))
3743            .expect("apply");
3744        report(
3745            &index,
3746            &crate::test_support::read_of(&index, query.clone()),
3747            &Provenance {
3748                scan_started_at: Some(UNIX_EPOCH + Duration::from_secs(1_786_386_151)),
3749                generated_at: UNIX_EPOCH + Duration::from_secs(1_786_386_152),
3750                source: ReportSource::ColdScan,
3751                complete: true,
3752                errors: Vec::new(),
3753            },
3754        )
3755        .expect("report")
3756    }
3757
3758    /// Whether a rendered line is a view header rather than a data row.
3759    ///
3760    /// Blank lines are excluded explicitly: `all` is vacuously true on an empty line, so
3761    /// the separator between blocks would otherwise count as a header.
3762    fn is_view_header_line(line: &str) -> bool {
3763        !line.is_empty() && line.chars().all(|c| c.is_ascii_uppercase())
3764    }
3765
3766    /// A structural check that output is well-formed JSON.
3767    ///
3768    /// Hand-written serializers earn their keep only if something proves they balance, so
3769    /// this walks the text tracking nesting depth and string state.
3770    fn is_valid_json(text: &str) -> bool {
3771        let (mut depth, mut in_string, mut escaped) = (0i32, false, false);
3772        for ch in text.chars() {
3773            if in_string {
3774                match ch {
3775                    _ if escaped => escaped = false,
3776                    '\\' => escaped = true,
3777                    '"' => in_string = false,
3778                    _ => {}
3779                }
3780                continue;
3781            }
3782            match ch {
3783                '"' => in_string = true,
3784                '{' | '[' => depth += 1,
3785                '}' | ']' => {
3786                    depth -= 1;
3787                    if depth < 0 {
3788                        return false;
3789                    }
3790                }
3791                _ => {}
3792            }
3793        }
3794        depth == 0 && !in_string
3795    }
3796
3797    /// Remove insignificant JSON whitespace without touching string contents.
3798    ///
3799    /// Exact layout is intentionally allowed to change when the structural sink changes;
3800    /// field names, values, and ordering remain part of the wire promise.
3801    fn compact_json(text: &str) -> String {
3802        let mut compact = String::with_capacity(text.len());
3803        let (mut in_string, mut escaped) = (false, false);
3804        for ch in text.chars() {
3805            if in_string {
3806                compact.push(ch);
3807                match ch {
3808                    _ if escaped => escaped = false,
3809                    '\\' => escaped = true,
3810                    '"' => in_string = false,
3811                    _ => {}
3812                }
3813            } else if ch == '"' {
3814                in_string = true;
3815                compact.push(ch);
3816            } else if !ch.is_ascii_whitespace() {
3817                compact.push(ch);
3818            }
3819        }
3820        compact
3821    }
3822
3823    /// Formats are serializations, not features: no view may lack one.
3824    ///
3825    /// Driven from `ALL_TEST_VIEWS` rather than a hand-written list, because a list is
3826    /// exactly what goes stale — `largest` and `recent` would not have been in it.
3827    #[test]
3828    fn every_view_renders_in_every_format() {
3829        for view in ALL_TEST_VIEWS {
3830            let report = fixture(&[view]);
3831            for format in [Format::Text, Format::Json, Format::Jsonl, Format::Yaml] {
3832                let rendered = render(&report, format, false);
3833                assert!(!rendered.trim().is_empty(), "{view:?} in {format:?} rendered nothing");
3834                if format != Format::Text {
3835                    assert!(
3836                        rendered.contains("\"schema\"") || rendered.contains("schema:"),
3837                        "{view:?} as {format:?} carries no schema: {rendered:.120}"
3838                    );
3839                }
3840            }
3841        }
3842    }
3843
3844    #[test]
3845    fn text_tree_restores_compact_bars_and_keeps_structural_indent_in_the_name_column() {
3846        // Apparent, so both rows print sizes of one width and the alignment below is about
3847        // the layout rather than about which sizes happen to round to the same block.
3848        let apparent = Query {
3849            views: vec![ViewSpec::Tree],
3850            selection: crate::query::Selection {
3851                size: crate::query::SizeMetric::Apparent,
3852                ..crate::query::Selection::default()
3853            },
3854            ..Query::default()
3855        };
3856        let text = render(&fixture_for(&apparent), Format::Text, false);
3857        assert_eq!(
3858            text,
3859            concat!(
3860                "██████████   100%       120 B  . 2 files\n",
3861                "████████░░    83%       100 B    src/ 1 file\n",
3862                "████████░░    83%       100 B      main.rs\n",
3863                "██░░░░░░░░    17%        20 B    notes.md\n",
3864            )
3865        );
3866
3867        let lines: Vec<&str> = text.lines().collect();
3868        assert_eq!(lines[0].find("120 B"), lines[1].find("100 B"));
3869        assert_eq!(lines[0].find('█'), lines[1].find('█'));
3870    }
3871
3872    #[test]
3873    fn language_text_uses_human_names_and_aligns_suffixes_with_color() {
3874        let mut index = Index::new_with_scope("/root", ScanScope::default());
3875        index
3876            .apply(&Observation::new(vec![
3877                Op::Upsert {
3878                    path: PathBuf::from("main.cpp"),
3879                    kind: EntryKind::File,
3880                    attrs: attrs(100, 10),
3881                },
3882                Op::Upsert {
3883                    path: PathBuf::from("main.js"),
3884                    kind: EntryKind::File,
3885                    attrs: attrs(100, 20),
3886                },
3887            ]))
3888            .expect("apply");
3889        let report = report(
3890            &index,
3891            &crate::test_support::read_of(
3892                &index,
3893                Query { views: vec![ViewSpec::Languages], ..Query::default() },
3894            ),
3895            &Provenance {
3896                scan_started_at: None,
3897                generated_at: UNIX_EPOCH,
3898                source: ReportSource::ColdScan,
3899                complete: true,
3900                errors: Vec::new(),
3901            },
3902        )
3903        .expect("report");
3904
3905        let plain = render(&report, Format::Text, false);
3906        assert!(plain.contains("C++"), "{plain}");
3907        assert!(plain.contains("JavaScript"), "{plain}");
3908        let plain_suffixes = plain
3909            .lines()
3910            .map(|line| line.find("1 file").expect("file count suffix"))
3911            .collect::<Vec<_>>();
3912        assert_eq!(plain_suffixes[0], plain_suffixes[1], "{plain}");
3913
3914        let colored = render(&report, Format::Text, true);
3915        let colored_suffixes = colored
3916            .lines()
3917            .map(|line| line.find("1 file").expect("colored file count suffix"))
3918            .collect::<Vec<_>>();
3919        assert_eq!(colored_suffixes[0], colored_suffixes[1], "{colored:?}");
3920
3921        let json = render(&report, Format::Json, false);
3922        assert!(json.contains("\"id\": \"cpp\""), "{json}");
3923        assert!(json.contains("\"id\": \"javascript\""), "{json}");
3924        assert!(!json.contains("\"id\": \"C++\""), "{json}");
3925        assert!(!json.contains("\"id\": \"JavaScript\""), "{json}");
3926    }
3927
3928    #[test]
3929    fn text_labels_a_percentage_that_is_not_a_byte_share() {
3930        let mut languages = fixture(&[ViewSpec::Languages]);
3931        if let Section::Metrics { summary, .. } = &mut languages.sections[0] {
3932            summary.share_metric = ShareMetric::CodeLines;
3933        } else {
3934            panic!("languages should be a metric section");
3935        }
3936        let text = render(&languages, Format::Text, false);
3937        assert!(text.starts_with("Percentage column: code lines\n"), "{text}");
3938
3939        let Section::Metrics { summary, .. } = &mut languages.sections[0] else {
3940            unreachable!("languages should stay a metric section");
3941        };
3942        summary.share_metric = ShareMetric::AllocatedBytes;
3943        let text = render(&languages, Format::Text, false);
3944        assert!(!text.contains("Percentage column:"), "{text}");
3945
3946        let mut documents = fixture(&[ViewSpec::Documents]);
3947        let Section::Metrics { summary, .. } = &mut documents.sections[0] else {
3948            panic!("documents should be a metric section");
3949        };
3950        summary.share_metric = ShareMetric::DocumentWords;
3951        let text = render(&documents, Format::Text, false);
3952        assert!(text.starts_with("Percentage column: document words\n"), "{text}");
3953    }
3954
3955    #[test]
3956    fn json_output_is_well_formed_for_every_view() {
3957        for view in [
3958            ViewSpec::Tree,
3959            ViewSpec::Extensions,
3960            ViewSpec::Types,
3961            ViewSpec::Families,
3962            ViewSpec::Languages,
3963            ViewSpec::Documents,
3964            ViewSpec::Files,
3965            ViewSpec::Summary,
3966        ] {
3967            let json = render(&fixture(&[view]), Format::Json, false);
3968            assert!(is_valid_json(&json), "unbalanced JSON for {view:?}:\n{json}");
3969        }
3970        let all = render(
3971            &fixture(&[
3972                ViewSpec::Tree,
3973                ViewSpec::Extensions,
3974                ViewSpec::Types,
3975                ViewSpec::Files,
3976                ViewSpec::Summary,
3977            ]),
3978            Format::Json,
3979            false,
3980        );
3981        assert!(is_valid_json(&all), "unbalanced JSON for a multi-view report:\n{all}");
3982    }
3983
3984    #[test]
3985    fn streaming_machine_writers_match_string_rendering() {
3986        struct Fails;
3987        impl std::io::Write for Fails {
3988            fn write(&mut self, _buffer: &[u8]) -> std::io::Result<usize> {
3989                Err(std::io::Error::other("closed"))
3990            }
3991
3992            fn flush(&mut self) -> std::io::Result<()> {
3993                Ok(())
3994            }
3995        }
3996
3997        let report = fixture(&[
3998            ViewSpec::Tree,
3999            ViewSpec::Extensions,
4000            ViewSpec::Types,
4001            ViewSpec::Files,
4002            ViewSpec::Summary,
4003        ]);
4004        for format in [Format::Json, Format::Jsonl, Format::Yaml] {
4005            let expected = render(&report, format, false);
4006            let mut streamed = Vec::new();
4007            write(&report, format, false, &mut streamed).expect("stream report");
4008            assert_eq!(streamed, expected.as_bytes(), "{format:?} bytes differ");
4009        }
4010
4011        let error = write(&report, Format::Json, false, &mut Fails).expect_err("writer fails");
4012        assert_eq!(error.kind(), std::io::ErrorKind::Other);
4013    }
4014
4015    #[test]
4016    fn streaming_tree_walk_handles_many_siblings_without_collecting_output() {
4017        #[derive(Default)]
4018        struct Count(u64);
4019        impl std::io::Write for Count {
4020            fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
4021                self.0 = self.0.saturating_add(buffer.len() as u64);
4022                Ok(buffer.len())
4023            }
4024
4025            fn flush(&mut self) -> std::io::Result<()> {
4026                Ok(())
4027            }
4028        }
4029
4030        let mut report = fixture(&[ViewSpec::Tree]);
4031        let Section::Tree { root: Some(root), .. } = &mut report.sections[0] else {
4032            panic!("tree fixture must contain a tree");
4033        };
4034        let template = root.children[0].clone();
4035        root.children = (0..10_000)
4036            .map(|index| {
4037                let mut child = template.clone();
4038                child.name = format!("child-{index}");
4039                child.path = PathBuf::from(&child.name);
4040                child
4041            })
4042            .collect();
4043
4044        let mut output = Count::default();
4045        write(&report, Format::Json, false, &mut output).expect("stream wide report");
4046        assert!(output.0 > 1_000_000, "wide fixture must exercise substantial output");
4047    }
4048
4049    #[test]
4050    fn nested_json_separates_siblings_without_a_trailing_comma() {
4051        // The original fixture had no directory with two children, so a balanced-but-
4052        // invalid `[{a}{b},]` passed the structural check. Sibling separators need a
4053        // case that actually has siblings, at more than one level.
4054        let mut index = Index::new_with_scope("/root", ScanScope::default());
4055        index
4056            .apply(&Observation::new(vec![
4057                Op::Upsert {
4058                    path: PathBuf::from("a"),
4059                    kind: EntryKind::Dir,
4060                    attrs: Attrs::default(),
4061                },
4062                Op::Upsert {
4063                    path: PathBuf::from("b"),
4064                    kind: EntryKind::Dir,
4065                    attrs: Attrs::default(),
4066                },
4067                Op::Upsert {
4068                    path: PathBuf::from("c"),
4069                    kind: EntryKind::Dir,
4070                    attrs: Attrs::default(),
4071                },
4072                Op::Upsert {
4073                    path: PathBuf::from("a/inner"),
4074                    kind: EntryKind::Dir,
4075                    attrs: Attrs::default(),
4076                },
4077                Op::Upsert {
4078                    path: PathBuf::from("a/other"),
4079                    kind: EntryKind::Dir,
4080                    attrs: Attrs::default(),
4081                },
4082            ]))
4083            .expect("apply");
4084        let report = report(
4085            &index,
4086            &crate::test_support::read_of(
4087                &index,
4088                Query {
4089                    selection: Selection {
4090                        depth: Some(Bound::All),
4091                        min_share: Some(crate::query::ShareThreshold::parse("0%").expect("share")),
4092                        ..Selection::default()
4093                    },
4094                    views: vec![ViewSpec::Tree],
4095                    ..Query::default()
4096                },
4097            ),
4098            &Provenance {
4099                scan_started_at: None,
4100                generated_at: UNIX_EPOCH,
4101                source: ReportSource::ColdScan,
4102                complete: true,
4103                errors: Vec::new(),
4104            },
4105        )
4106        .expect("report");
4107
4108        let json = render(&report, Format::Json, false);
4109        assert!(is_valid_json(&json), "{json}");
4110        assert!(!json.contains("}{"), "siblings must be separated:\n{json}");
4111        assert!(!json.contains(",]"), "no trailing comma before a close:\n{json}");
4112        assert!(!json.contains("[,"), "no leading comma after an open:\n{json}");
4113        // Three top-level siblings and two nested ones must all be present.
4114        for name in ["\"a\"", "\"b\"", "\"c\"", "\"inner\"", "\"other\""] {
4115            assert!(json.contains(name), "missing {name} in:\n{json}");
4116        }
4117    }
4118
4119    #[test]
4120    fn jsonl_emits_one_document_per_line() {
4121        let rendered =
4122            render(&fixture(&[ViewSpec::Extensions, ViewSpec::Summary]), Format::Jsonl, false);
4123        let lines: Vec<&str> = rendered.lines().collect();
4124        assert_eq!(lines.len(), 3, "one envelope plus one line per section");
4125        for line in &lines {
4126            assert!(is_valid_json(line), "line is not a JSON document: {line}");
4127        }
4128        assert!(lines[0].contains("\"schema\""), "the envelope carries provenance");
4129    }
4130
4131    #[test]
4132    fn machine_output_carries_the_schema_and_provenance() {
4133        let json = render(&fixture(&[ViewSpec::Summary]), Format::Json, false);
4134        assert!(json.contains("\"schema\": \"fdu.report/10\""));
4135        assert!(json.contains("\"request\": {"));
4136        assert!(json.contains("\"status\": {"));
4137        assert!(json.contains("\"provenance\": {"));
4138        assert!(json.contains("\"source\": \"cold_scan\""));
4139        assert!(json.contains("\"complete\": true"));
4140        // Timestamps render in the same grammar the CLI accepts back as a watermark.
4141        assert!(json.contains("\"scan_started_at\": \"2026-08-10T18:22:31.000000000Z\""), "{json}");
4142        assert!(json.contains("\"generated_at\": \"2026-08-10T18:22:32.000000000Z\""), "{json}");
4143    }
4144
4145    #[test]
4146    fn the_schema_constant_is_the_versioning_promise() {
4147        // Fails loudly when the schema string moves, so a field rename cannot ship
4148        // without a deliberate version bump and a golden update.
4149        assert_eq!(REPORT_SCHEMA, "fdu.report/10");
4150        assert_eq!(CONTENT_REPORT_SCHEMA, REPORT_SCHEMA);
4151    }
4152
4153    /// Every format says whether ignore rules were read and which files were refused, and
4154    /// text names the directories and the knob as the requesting surface spells it.
4155    #[test]
4156    fn every_format_states_the_ignore_rules_a_report_could_apply() {
4157        let unobserved = crate::test_support::not_observing_controls();
4158        let mut blind = Index::new_with_scope("/root", unobserved);
4159        blind
4160            .apply(&Observation::new(vec![Op::Upsert {
4161                path: PathBuf::from("a.txt"),
4162                kind: EntryKind::File,
4163                attrs: attrs(1, 1),
4164            }]))
4165            .expect("apply");
4166        let provenance = Provenance {
4167            scan_started_at: None,
4168            generated_at: UNIX_EPOCH,
4169            source: ReportSource::ColdScan,
4170            complete: true,
4171            errors: Vec::new(),
4172        };
4173        let query = Query { views: vec![ViewSpec::Summary], ..Query::default() };
4174        let blind_report =
4175            report(&blind, &crate::test_support::read_of(&blind, query.clone()), &provenance)
4176                .expect("report");
4177        assert!(render(&blind_report, Format::Json, false).contains("\"ignore_rules\": null"));
4178        assert!(render(&blind_report, Format::Yaml, false).contains("\nignore_rules: null\n"));
4179        assert!(blind_report.notes.is_empty());
4180
4181        let mut observed =
4182            Index::new_with_scope("/root", crate::test_support::observing_controls());
4183        let mut long_line = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
4184        long_line.push(b'\n');
4185        observed
4186            .apply(&Observation::new(vec![
4187                Op::Upsert {
4188                    path: PathBuf::from("vendor"),
4189                    kind: EntryKind::Dir,
4190                    attrs: attrs(0, 1),
4191                },
4192                Op::ControlUpsert {
4193                    path: PathBuf::from(".gitignore"),
4194                    source: b"*.log\n".to_vec(),
4195                },
4196                Op::ControlUpsert { path: PathBuf::from("vendor/.gitignore"), source: long_line },
4197            ]))
4198            .expect("apply");
4199        // The platform spells the refused path, so Windows writes a backslash.
4200        let refused = Path::new("vendor").join(".gitignore");
4201        let refused = refused.to_string_lossy();
4202        let json = render(
4203            &report(
4204                &observed,
4205                &crate::test_support::read_of(&observed, query.clone()),
4206                &provenance,
4207            )
4208            .expect("report"),
4209            Format::Json,
4210            false,
4211        );
4212        let expected = format!(
4213            "\"ignore_rules\": {{\"limits\": {{\"budget\": 4194304, \"line_limit\": 16384}}, \
4214             \"applied\": 1, \"rules\": 1, \"refused\": 1, \"refusals\": [{{\"path\": {}, \"reason\": \
4215             \"line_limit\"}}]}}",
4216            quote(&refused)
4217        );
4218        assert!(compact_json(&json).contains(&compact_json(&expected)), "{json}");
4219        assert!(json.contains("\"complete\": true"), "a refusal is not an operational partial");
4220        let yaml = render(
4221            &report(
4222                &observed,
4223                &crate::test_support::read_of(&observed, query.clone()),
4224                &provenance,
4225            )
4226            .expect("report"),
4227            Format::Yaml,
4228            false,
4229        );
4230        let expected = format!(
4231            "ignore_rules:\n  limits: {{budget: 4194304, line_limit: 16384}}\n  applied: 1\n  rules: 1\n  \
4232             refused: 1\n  refusals:\n    -\n      path: {}\n      reason: line_limit\n",
4233            yaml_scalar(&refused)
4234        );
4235        assert!(yaml.contains(&expected), "{yaml}");
4236
4237        let flags = Query { axes: &crate::query::AxisNames::FLAGS, ..query.clone() };
4238        let candidate =
4239            report(&observed, &crate::test_support::read_of(&observed, flags), &provenance)
4240                .expect("report");
4241        let text = render(&candidate, Format::Text, false);
4242        assert!(
4243            !text.contains("note:") && !text.contains("tip:"),
4244            "stdout holds only formatted data"
4245        );
4246        let lines = diagnostics(&candidate);
4247        assert!(
4248            lines.iter().any(|line| line.starts_with("note: ignore classification incomplete:")
4249                && line.contains("vendor"))
4250        );
4251        assert!(lines.last().expect("remedy").contains("--gitignore-line-limit"));
4252        let fields =
4253            report(&observed, &crate::test_support::read_of(&observed, query), &provenance)
4254                .expect("report");
4255        assert!(report_tips(&fields).iter().any(|tip| tip.contains("control_line_limit")));
4256        assert!(!diagnostics(&fields).iter().any(|line| line.contains("--gitignore-line-limit")));
4257    }
4258
4259    /// Every row that carries an ignored share says so in every format: text appends it
4260    /// only when something is ignored and the selection is not ignored entries alone, and
4261    /// machine formats write a zero share when nothing is and `null` when no rule was read.
4262    #[test]
4263    fn every_format_carries_each_rows_ignored_share() {
4264        let build = |scope: ScanScope| {
4265            let mut index = Index::new_with_scope("/root", scope);
4266            let mut ops = vec![
4267                Op::Upsert {
4268                    path: PathBuf::from("dist"),
4269                    kind: EntryKind::Dir,
4270                    attrs: Attrs::default(),
4271                },
4272                Op::Upsert {
4273                    path: PathBuf::from("dist/a.gz"),
4274                    kind: EntryKind::File,
4275                    attrs: attrs(128, 10),
4276                },
4277                Op::Upsert {
4278                    path: PathBuf::from("src"),
4279                    kind: EntryKind::Dir,
4280                    attrs: Attrs::default(),
4281                },
4282                Op::Upsert {
4283                    path: PathBuf::from("src/b.rs"),
4284                    kind: EntryKind::File,
4285                    attrs: attrs(36, 20),
4286                },
4287            ];
4288            if scope.observes_controls() {
4289                ops.insert(
4290                    0,
4291                    Op::ControlUpsert {
4292                        path: PathBuf::from(".gitignore"),
4293                        source: b"dist/\n".to_vec(),
4294                    },
4295                );
4296            }
4297            index.apply(&Observation::new(ops)).expect("apply");
4298            index
4299        };
4300        let provenance = Provenance {
4301            scan_started_at: None,
4302            generated_at: UNIX_EPOCH,
4303            source: ReportSource::ColdScan,
4304            complete: true,
4305            errors: Vec::new(),
4306        };
4307        let views = vec![ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions, ViewSpec::Files];
4308        let query = |ignored| Query {
4309            views: views.clone(),
4310            selection: Selection {
4311                ignored,
4312                size: SizeMetric::Apparent,
4313                limit: Some(Bound::All),
4314                ..Selection::default()
4315            },
4316            ..Query::default()
4317        };
4318
4319        let observed = build(crate::test_support::observing_controls());
4320        let text = render(
4321            &report(
4322                &observed,
4323                &crate::test_support::read_of(&observed, query(IgnoredEntries::Include)),
4324                &provenance,
4325            )
4326            .expect("report"),
4327            Format::Text,
4328            false,
4329        );
4330        assert_eq!(
4331            text,
4332            concat!(
4333                "SUMMARY\n",
4334                "     164 B  2 files, 2 directories (128 B gitignored)\n",
4335                "\n",
4336                "TREE\n",
4337                "██████████   100%       164 B  . 2 files (128 B gitignored)\n",
4338                "████████░░    78%       128 B    dist/ 1 file (128 B gitignored)\n",
4339                "████████░░    78%       128 B      a.gz (128 B gitignored)\n",
4340                "██░░░░░░░░    22%        36 B    src/ 1 file\n",
4341                "██░░░░░░░░    22%        36 B      b.rs\n",
4342                "\n",
4343                "EXTENSIONS\n",
4344                "     128 B  .gz          1 file (128 B gitignored)\n",
4345                "      36 B  .rs          1 file\n",
4346                "\n",
4347                "FILES\n",
4348                "dist\n",
4349                "dist/a.gz\n",
4350                "src\n",
4351                "src/b.rs\n",
4352            )
4353            .replace("dist/a.gz", &format!("dist{}a.gz", std::path::MAIN_SEPARATOR))
4354            .replace("src/b.rs", &format!("src{}b.rs", std::path::MAIN_SEPARATOR))
4355        );
4356        // A share of ignored directories alone holds no bytes, so text says nothing of it.
4357        let dirs_only = IgnoredTally { files: 0, dirs: 1, bytes: 0, allocated: 0 };
4358        assert_eq!(
4359            ignored_suffix(Some(dirs_only), SizeMetric::Apparent, IgnoredEntries::Include, false),
4360            ""
4361        );
4362        let large_ignored = IgnoredTally { files: 1, dirs: 0, bytes: 1 << 30, allocated: 1 << 30 };
4363        let suffix = ignored_suffix(
4364            Some(large_ignored),
4365            SizeMetric::Apparent,
4366            IgnoredEntries::Include,
4367            true,
4368        );
4369        assert!(suffix.contains(&paint("1.0 GiB", STYLE_DETAIL.bold(), true)), "{suffix:?}");
4370        assert_eq!(strip_ansi(&suffix), " (1.0 GiB gitignored)");
4371        let only = render(
4372            &report(
4373                &observed,
4374                &crate::test_support::read_of(&observed, query(IgnoredEntries::Only)),
4375                &provenance,
4376            )
4377            .expect("report"),
4378            Format::Text,
4379            false,
4380        );
4381        assert!(only.contains("     128 B  1 file, 1 directory\n"), "{only}");
4382        assert!(!only.contains("ignored"), "every row is ignored, so none repeats it: {only}");
4383
4384        let json = render(
4385            &report(
4386                &observed,
4387                &crate::test_support::read_of(&observed, query(IgnoredEntries::Include)),
4388                &provenance,
4389            )
4390            .expect("report"),
4391            Format::Json,
4392            false,
4393        );
4394        assert!(is_valid_json(&json), "{json}");
4395        let compact = compact_json(&json);
4396        for expected in [
4397            "\"summary\": {\"files\": 2, \"dirs\": 2, \"bytes\": 164, \"allocated\": 1024, \
4398             \"ignored\": {\"files\": 1, \"dirs\": 1, \"bytes\": 128, \"allocated\": 512}, ",
4399            "\"name\": \"src\", \"path\": \"src\", \"kind\": \"dir\", \"entry_ignored\": false, \"bytes\": 36, \
4400             \"allocated\": 512, \"files\": 1, \"dirs\": 0, \"ignored\": {\"files\": 0, \
4401             \"dirs\": 0, \"bytes\": 0, \"allocated\": 0}, ",
4402            "{\"extension\": \".gz\", \"files\": 1, \"bytes\": 128, \"allocated\": 512, \
4403             \"ignored\": {\"files\": 1, \"bytes\": 128, \"allocated\": 512}}",
4404            "\"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}",
4405        ] {
4406            assert!(compact.contains(&compact_json(expected)), "missing {expected}\nin {json}");
4407        }
4408        let yaml = render(
4409            &report(
4410                &observed,
4411                &crate::test_support::read_of(&observed, query(IgnoredEntries::Include)),
4412                &provenance,
4413            )
4414            .expect("report"),
4415            Format::Yaml,
4416            false,
4417        );
4418        assert!(
4419            yaml.contains(
4420                "      allocated: 1024\n      ignored: {files: 1, dirs: 1, bytes: 128, \
4421                 allocated: 512}\n      newest_mtime_ns: 20\n"
4422            ),
4423            "{yaml}"
4424        );
4425        assert!(yaml.contains("        ignored: true\n"), "{yaml}");
4426
4427        let blind = build(crate::test_support::not_observing_controls());
4428        let blind_report = report(
4429            &blind,
4430            &crate::test_support::read_of(&blind, query(IgnoredEntries::Include)),
4431            &provenance,
4432        )
4433        .expect("report");
4434        assert!(!render(&blind_report, Format::Text, false).contains("ignored"));
4435        let json = render(&blind_report, Format::Json, false);
4436        assert!(!json.contains("\"ignored\": {"), "never a zero share for an unread rule: {json}");
4437        assert!(json.contains("\"ignored\": null"), "{json}");
4438        assert!(render(&blind_report, Format::Yaml, false).contains("ignored: null\n"));
4439    }
4440
4441    #[test]
4442    fn every_report_uses_one_schema_and_states_nullable_analysis() {
4443        let metadata = render(&fixture(&[ViewSpec::Tree]), Format::Json, false);
4444        assert!(metadata.contains("\"schema\": \"fdu.report/10\""));
4445        assert!(metadata.contains("\"analysis\": null"));
4446
4447        let metrics = render(&fixture(&[ViewSpec::Types]), Format::Json, false);
4448        assert!(metrics.contains("\"schema\": \"fdu.report/10\""));
4449        assert!(metrics.contains("\"analysis\": null"));
4450        assert!(metrics.contains("\"share\": {\"numerator\":"));
4451    }
4452
4453    /// The change stream carries the same promise the report does.
4454    ///
4455    /// A constant assertion alone would not: it pins the version string while leaving the
4456    /// record's shape free to change underneath it, which is the failure the promise
4457    /// exists to prevent. This pins the whole record, so adding, renaming, or reordering
4458    /// a field fails here and forces a deliberate version bump.
4459    ///
4460    /// `ignored` was added to `fdu.stream/2` before the first release, so no consumer has
4461    /// ever read the earlier draft shape it extends.
4462    #[cfg(feature = "watch")]
4463    #[test]
4464    fn a_stream_record_is_pinned_field_by_field() {
4465        use crate::{Change, ChangeKind};
4466
4467        assert_eq!(STREAM_SCHEMA, "fdu.stream/2");
4468
4469        let upsert = Change {
4470            path: ["src", "main.rs"].iter().collect(),
4471            kind: ChangeKind::Upsert,
4472            entry_kind: Some(EntryKind::File),
4473            bytes: Some(2_048),
4474            allocated: Some(4_096),
4475            mtime_ns: Some(1_700_000_000_000_000_000),
4476            ignored: Some(false),
4477            clock: 7,
4478        };
4479        // Path separators differ by platform, so the expectation is built the same way
4480        // the renderer builds it rather than hardcoding a slash.
4481        let path = upsert.path.to_string_lossy().replace('\\', "\\\\");
4482        assert_eq!(
4483            render_change(&upsert, Format::Json),
4484            format!(
4485                "{{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"upsert\", \
4486                 \"path\": \"{path}\", \"clock\": 7, \"kind\": \"file\", \"bytes\": 2048, \
4487                 \"allocated\": 4096, \"mtime_ns\": 1700000000000000000, \"ignored\": false}}"
4488            )
4489        );
4490
4491        // A run that read no ignore rules classifies nothing, and the field is absent
4492        // rather than false: the same distinction every report row draws.
4493        let unclassified = Change { ignored: None, ..upsert.clone() };
4494        assert_eq!(
4495            render_change(&unclassified, Format::Json),
4496            format!(
4497                "{{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"upsert\", \
4498                 \"path\": \"{path}\", \"clock\": 7, \"kind\": \"file\", \"bytes\": 2048, \
4499                 \"allocated\": 4096, \"mtime_ns\": 1700000000000000000}}"
4500            )
4501        );
4502
4503        // A removal has no metadata to report, and the optional fields must be absent
4504        // rather than null: a consumer distinguishes "gone" from "unknown" by their
4505        // absence.
4506        let removed = Change {
4507            path: PathBuf::from("gone.txt"),
4508            kind: ChangeKind::Remove,
4509            entry_kind: None,
4510            bytes: None,
4511            allocated: None,
4512            mtime_ns: None,
4513            ignored: None,
4514            clock: 8,
4515        };
4516        assert_eq!(
4517            render_change(&removed, Format::Json),
4518            "{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"remove\", \
4519             \"path\": \"gone.txt\", \"clock\": 8}"
4520        );
4521
4522        // A removal an ignore-rule edit caused is the one that carries a classification:
4523        // the entry is still on disk, and the new bit is why it left the selection.
4524        let reclassified =
4525            Change { path: PathBuf::from("debug.log"), ignored: Some(true), ..removed.clone() };
4526        assert_eq!(
4527            render_change(&reclassified, Format::Json),
4528            "{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"remove\", \
4529             \"path\": \"debug.log\", \"clock\": 8, \"ignored\": true}"
4530        );
4531
4532        // An invalidation says the consumer's view may have gaps. It is the one record
4533        // that must never be dropped, so its shape is pinned too.
4534        let invalidated = Change {
4535            path: PathBuf::from("subtree"),
4536            kind: ChangeKind::Invalidate,
4537            entry_kind: None,
4538            bytes: None,
4539            allocated: None,
4540            mtime_ns: None,
4541            ignored: None,
4542            clock: 9,
4543        };
4544        assert_eq!(
4545            render_change(&invalidated, Format::Json),
4546            "{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"invalidate\", \
4547             \"path\": \"subtree\", \"clock\": 9}"
4548        );
4549
4550        // Text is the greppable form: path first, operation second, tab-separated.
4551        assert_eq!(render_change(&removed, Format::Text), "gone.txt\tremove");
4552    }
4553
4554    #[test]
4555    fn a_files_view_prints_one_path_per_line_and_nothing_else() {
4556        // The property that makes `fdu --view files | xargs` work. It is why the view
4557        // header is conditional: a lone files view is a path listing, not a table that
4558        // needs labelling, so nothing is prepended to it.
4559        let text = render(&fixture(&[ViewSpec::Files]), Format::Text, false);
4560        for line in text.lines() {
4561            assert!(!line.contains(' '), "text files output must be bare paths, got {line:?}");
4562        }
4563        let expected: PathBuf = ["src", "main.rs"].iter().collect();
4564        let expected = expected.display().to_string();
4565        assert!(text.lines().any(|line| line == expected), "{text}");
4566    }
4567
4568    #[test]
4569    fn several_views_are_labelled_and_a_lone_view_is_left_bare() {
4570        // Concatenated blocks of similar-looking rows were the problem: a reader had to
4571        // recover which view produced which table from the order they were requested in.
4572        let text = render(
4573            &fixture(&[ViewSpec::Tree, ViewSpec::Types, ViewSpec::Summary]),
4574            Format::Text,
4575            false,
4576        );
4577        let headers: Vec<&str> = text.lines().filter(|line| is_view_header_line(line)).collect();
4578        assert_eq!(headers, ["TREE", "TYPES", "SUMMARY"], "{text}");
4579
4580        // Each header sits directly above the rows it labels, and one blank line
4581        // separates the blocks.
4582        let lines: Vec<&str> = text.lines().collect();
4583        for (index, line) in lines.iter().enumerate() {
4584            if headers.contains(line) {
4585                assert!(
4586                    lines.get(index + 1).is_some_and(|next| !next.is_empty()),
4587                    "header {line} must sit directly above its rows:\n{text}"
4588                );
4589                if index > 0 {
4590                    assert!(
4591                        lines[index - 1].is_empty(),
4592                        "a blank line must precede header {line}:\n{text}"
4593                    );
4594                }
4595            }
4596        }
4597
4598        // The same views alone keep the pre-header layout exactly.
4599        for view in [ViewSpec::Tree, ViewSpec::Types, ViewSpec::Summary] {
4600            let lone = render(&fixture(&[view]), Format::Text, false);
4601            assert!(
4602                !lone.lines().any(is_view_header_line),
4603                "{view:?} alone must not be labelled:\n{lone}"
4604            );
4605        }
4606    }
4607
4608    #[test]
4609    fn view_headers_are_colorized_only_when_color_is_on() {
4610        let views = [ViewSpec::Tree, ViewSpec::Summary];
4611        let plain = render(&fixture(&views), Format::Text, false);
4612        assert!(plain.starts_with("TREE\n"), "{plain}");
4613        assert!(!plain.contains('\u{1b}'), "uncolored text carries no escapes: {plain:?}");
4614
4615        let colored = render(&fixture(&views), Format::Text, true);
4616        assert!(colored.contains(&paint("TREE", STYLE_HEADING, true)), "{colored:?}");
4617        assert!(colored.contains(&paint("SUMMARY", STYLE_HEADING, true)), "{colored:?}");
4618    }
4619
4620    #[test]
4621    fn human_rows_style_only_their_semantic_spans() {
4622        let ignored = IgnoredTally { files: 1, dirs: 0, bytes: 43, allocated: 43 };
4623        let mut tree = fixture(&[ViewSpec::Tree]);
4624        let Section::Tree { root: Some(root), .. } = &mut tree.sections[0] else { panic!("tree") };
4625        root.name = "a(b)\n界".into();
4626        root.files = 3_508;
4627        root.ignored = Some(ignored);
4628        let plain = render(&tree, Format::Text, false);
4629        let colored = render(&tree, Format::Text, true);
4630        assert!(
4631            colored.contains(&format!(
4632                "{} 3,508 files {}",
4633                human_name("a(b)\n界", EntryKind::Dir, None, true),
4634                detail("(43 B gitignored)", true)
4635            )),
4636            "{colored:?}"
4637        );
4638        assert_eq!(strip_ansi(&colored).replace('·', "░").replace('▒', "█"), plain);
4639        assert!(!colored.contains("a(b)\n界"));
4640
4641        let mut summary = fixture(&[ViewSpec::Summary]);
4642        let Section::Summary(row) = &mut summary.sections[0] else { panic!("summary") };
4643        row.files = 3_508;
4644        row.ignored = Some(ignored);
4645        let colored = render(&summary, Format::Text, true);
4646        assert!(
4647            colored.contains(&format!(
4648                "3,508 files, 1 directory {}",
4649                detail("(43 B gitignored)", true)
4650            )),
4651            "{colored:?}"
4652        );
4653        assert_eq!(strip_ansi(&colored), render(&summary, Format::Text, false));
4654
4655        let mut types = fixture(&[ViewSpec::Extensions]);
4656        let Section::Extensions { rows, .. } = &mut types.sections[0] else { panic!("extensions") };
4657        rows[0].extension = ".(txt)".into();
4658        rows[0].files = 3_508;
4659        rows[0].ignored = Some(ignored);
4660        let colored = render(&types, Format::Text, true);
4661        assert!(colored.contains(&paint(".(txt)", STYLE_CATEGORY, true)), "{colored:?}");
4662        assert!(
4663            colored.contains(&format!("3,508 files {}", detail("(43 B gitignored)", true))),
4664            "{colored:?}"
4665        );
4666        assert_eq!(strip_ansi(&colored), render(&types, Format::Text, false));
4667    }
4668
4669    #[test]
4670    fn metric_breakdowns_and_ranked_paths_keep_span_boundaries() {
4671        let mut metrics = fixture(&[ViewSpec::Types]);
4672        let Section::Metrics { summary, .. } = &mut metrics.sections[0] else { panic!("metrics") };
4673        let row = &mut summary.rows[0];
4674        row.files = 1_234;
4675        row.metrics.physical_lines = Some(477_298);
4676        row.metrics.nonblank_lines = Some(439_949);
4677        row.metrics.blank_lines = Some(37_349);
4678        let colored = render(&metrics, Format::Text, true);
4679        assert!(
4680            colored.contains(&format!(
4681                "1,234 files, 477,298 lines {}",
4682                detail("(439,949 nonblank, 37,349 blank)", true)
4683            )),
4684            "{colored:?}"
4685        );
4686        assert_eq!(strip_ansi(&colored), render(&metrics, Format::Text, false));
4687
4688        let mut largest = fixture(&[ViewSpec::Largest]);
4689        let Section::Files { rows, .. } = &mut largest.sections[0] else { panic!("files") };
4690        rows[0].path = PathBuf::from("a(b)\n界.rs");
4691        let colored = render(&largest, Format::Text, true);
4692        assert!(colored.contains(&paint("a(b)\\n界.rs", STYLE_NAME, true)), "{colored:?}");
4693        assert_eq!(strip_ansi(&colored), render(&largest, Format::Text, false));
4694        for format in [Format::Json, Format::Jsonl, Format::Yaml] {
4695            assert!(!render(&largest, format, true).contains('\u{1b}'));
4696        }
4697    }
4698
4699    #[test]
4700    fn code_population_details_keep_known_and_unknown_contributions_visible() {
4701        use crate::query::{CodeLanguageRow, MetricShare};
4702        let tally = |lines| CodeTally {
4703            source_files: 1,
4704            analyzed_files: 1,
4705            metrics: crate::content::CodeMetrics { code_lines: lines, ..Default::default() },
4706            ..Default::default()
4707        };
4708        let mut overview = CodeOverview {
4709            population: IgnoredEntries::Include,
4710            selected: CodeTally { source_files: 3, analyzed_files: 3, ..tally(110) },
4711            non_ignored: Some(tally(80)),
4712            ignored: Some(tally(20)),
4713            unknown: tally(10),
4714            unclassified_files: 0,
4715            analyzed_languages: 1,
4716            total_languages: 1,
4717            share_omitted: 0,
4718            languages: vec![CodeLanguageRow {
4719                language: "rust".into(),
4720                selected: CodeTally { source_files: 3, analyzed_files: 3, ..tally(110) },
4721                non_ignored: Some(tally(80)),
4722                ignored: Some(tally(20)),
4723                unknown: tally(10),
4724                share: MetricShare { numerator: 110, denominator: 110 },
4725            }],
4726            share_metric: ShareMetric::CodeLines,
4727        };
4728        let mut report = fixture(&[ViewSpec::Summary]);
4729        report.sections = vec![Section::Code(Box::new(overview.clone()))];
4730        let colored = render(&report, Format::Text, true);
4731        assert!(colored.contains(&paint("TOTAL   ", AnsiStyle::new().bold(), true)), "{colored:?}");
4732        let colored_total = colored.lines().find(|line| line.contains("TOTAL")).expect("total row");
4733        assert_eq!(colored_total.matches("\x1b[1m").count(), 6, "all primary TOTAL cells are bold");
4734        assert!(colored.contains(&detail("(20 gitignored, 10 unknown)", true)), "{colored:?}");
4735        assert!(!colored.contains("non-gitignored"), "the complement is never repeated");
4736        let plain = strip_ansi(&colored);
4737        assert!(
4738            plain.lines().next().expect("header").contains("Analyzed files  Language"),
4739            "{plain}"
4740        );
4741        let total = plain.lines().find(|line| line.contains("TOTAL")).expect("total row");
4742        assert_eq!(
4743            total.split_whitespace().take(6).collect::<Vec<_>>(),
4744            ["110", "100.0%", "0", "0", "3/3", "TOTAL"]
4745        );
4746        assert_eq!(
4747            plain.matches("       110").count(),
4748            2,
4749            "row and TOTAL retain the same measured total"
4750        );
4751        assert_eq!(strip_ansi(&colored), render(&report, Format::Text, false));
4752        for format in [Format::Json, Format::Jsonl, Format::Yaml] {
4753            assert!(!render(&report, format, true).contains('\u{1b}'));
4754        }
4755        overview.population = IgnoredEntries::Only;
4756        overview.non_ignored = None;
4757        overview.ignored = None;
4758        overview.unknown = CodeTally::default();
4759        overview.languages[0].non_ignored = None;
4760        overview.languages[0].ignored = None;
4761        overview.languages[0].unknown = CodeTally::default();
4762        report.sections = vec![Section::Code(Box::new(overview))];
4763        let text = render(&report, Format::Text, false);
4764        assert!(!text.contains("gitignored") && !text.contains(" unknown"), "{text}");
4765    }
4766
4767    #[test]
4768    fn code_table_keeps_global_total_when_rows_are_bounded_and_marks_unmeasured_values() {
4769        use crate::query::{CodeLanguageRow, MetricShare};
4770        let mut overview = CodeOverview {
4771            population: IgnoredEntries::Include,
4772            selected: CodeTally {
4773                source_files: 2,
4774                analyzed_files: 2,
4775                metrics: crate::content::CodeMetrics { code_lines: 12, ..Default::default() },
4776                ..Default::default()
4777            },
4778            non_ignored: None,
4779            ignored: None,
4780            unknown: CodeTally::default(),
4781            unclassified_files: 0,
4782            analyzed_languages: 1,
4783            total_languages: 1,
4784            share_omitted: 0,
4785            languages: Vec::new(),
4786            share_metric: ShareMetric::CodeLines,
4787        };
4788        let mut report = fixture(&[ViewSpec::Summary]);
4789        report.sections = vec![Section::Code(Box::new(overview.clone()))];
4790        let bounded = render(&report, Format::Text, false);
4791        let total = bounded.lines().find(|line| line.contains("TOTAL")).expect("total row");
4792        assert_eq!(
4793            total.split_whitespace().take(6).collect::<Vec<_>>(),
4794            ["12", "100.0%", "0", "0", "2/2", "TOTAL"]
4795        );
4796        assert!(!bounded.contains("Rust"), "{bounded}");
4797
4798        overview.selected.analyzed_files = 0;
4799        overview.selected.metrics = crate::content::CodeMetrics::default();
4800        overview.selected.coverage.insert(CoverageReason::Unsupported, 2);
4801        overview.analyzed_languages = 0;
4802        overview.non_ignored = Some(CodeTally::default());
4803        overview.ignored = Some(CodeTally::default());
4804        overview.languages = vec![CodeLanguageRow {
4805            language: "rust".into(),
4806            selected: CodeTally { source_files: 2, ..Default::default() },
4807            non_ignored: Some(CodeTally::default()),
4808            ignored: Some(CodeTally::default()),
4809            unknown: CodeTally::default(),
4810            share: MetricShare { numerator: 0, denominator: 0 },
4811        }];
4812        report.sections = vec![Section::Code(Box::new(overview))];
4813        let unmeasured = render(&report, Format::Text, false);
4814        let language = unmeasured.lines().find(|line| line.contains("Rust")).expect("language row");
4815        let total = unmeasured.lines().find(|line| line.contains("TOTAL")).expect("total row");
4816        assert_eq!(
4817            language.split_whitespace().take(6).collect::<Vec<_>>(),
4818            ["—", "—", "—", "—", "0/2", "Rust"]
4819        );
4820        assert_eq!(
4821            total.split_whitespace().take(6).collect::<Vec<_>>(),
4822            ["—", "—", "—", "—", "0/2", "TOTAL"]
4823        );
4824        assert!(!unmeasured.contains("gitignored"), "{unmeasured}");
4825        assert!(unmeasured.contains("2 unsupported"), "{unmeasured}");
4826        assert!(unmeasured.lines().all(|line| line.trim_end() == line), "{unmeasured:?}");
4827    }
4828
4829    #[test]
4830    fn percentage_and_unicode_width_keep_small_values_visible() {
4831        assert_eq!(human_percentage(1, 10_000, 1), "<0.1%");
4832        assert_eq!(human_percentage(1, 10_000, 0), "<1%");
4833        assert_eq!(human_percentage(0, 10_000, 1), "0.0%");
4834        assert_eq!(human_percentage(0, 0, 1), "—");
4835        assert_eq!(label_cell("界", 4, STYLE_CATEGORY, false), "界  ");
4836        assert_eq!(label_cell("e\u{301}", 4, STYLE_CATEGORY, false), "e\u{301}   ");
4837    }
4838
4839    #[test]
4840    fn no_machine_format_gains_a_text_header() {
4841        // Machine formats already name their view in a field. Text is a presentation
4842        // layer over the same report and must not leak into the versioned schemas.
4843        let views = [ViewSpec::Tree, ViewSpec::Types, ViewSpec::Files, ViewSpec::Summary];
4844        for format in [Format::Json, Format::Jsonl, Format::Yaml] {
4845            let rendered = render(&fixture(&views), format, false);
4846            for header in ["TREE", "TYPES", "FILES", "SUMMARY"] {
4847                assert!(!rendered.contains(header), "{format:?} leaked {header}:\n{rendered}");
4848            }
4849        }
4850    }
4851
4852    #[test]
4853    fn every_view_has_a_header_that_matches_its_wire_label() {
4854        // The two spellings are written out separately so a schema change and a
4855        // presentation change stay independent; this is what keeps them from drifting
4856        // apart by accident while they are meant to agree.
4857        for view in [
4858            ViewSpec::Tree,
4859            ViewSpec::Extensions,
4860            ViewSpec::Types,
4861            ViewSpec::Families,
4862            ViewSpec::Languages,
4863            ViewSpec::Documents,
4864            ViewSpec::Files,
4865            ViewSpec::Summary,
4866        ] {
4867            let header = view_header(view);
4868            assert_eq!(header, view.label().to_uppercase(), "{view:?}");
4869            assert!(
4870                !header.is_empty() && header.chars().all(|c| c.is_ascii_uppercase()),
4871                "{view:?}"
4872            );
4873        }
4874    }
4875
4876    #[test]
4877    fn yaml_quotes_only_what_would_be_ambiguous() {
4878        assert_eq!(yaml_scalar("cold_scan"), "cold_scan");
4879        assert_eq!(yaml_scalar("src/main.rs"), "src/main.rs");
4880        // Bare words YAML would read as another type have to be quoted.
4881        assert_eq!(yaml_scalar("true"), "\"true\"");
4882        assert_eq!(yaml_scalar("null"), "\"null\"");
4883        assert_eq!(yaml_scalar("12345"), "\"12345\"");
4884        assert_eq!(yaml_scalar(""), "\"\"");
4885        assert_eq!(yaml_scalar("has space"), "\"has space\"");
4886        assert_eq!(yaml_scalar("-leading-dash"), "\"-leading-dash\"");
4887    }
4888
4889    #[test]
4890    fn json_strings_escape_control_characters_and_quotes() {
4891        assert_eq!(quote("a\"b"), "\"a\\\"b\"");
4892        assert_eq!(quote("a\\b"), "\"a\\\\b\"");
4893        assert_eq!(quote("a\nb"), "\"a\\nb\"");
4894        assert_eq!(quote("a\u{1}b"), "\"a\\u0001b\"");
4895    }
4896
4897    #[test]
4898    fn format_values_parse_and_reject_by_name() {
4899        assert_eq!(Format::parse("json"), Some(Format::Json));
4900        assert_eq!(Format::parse("  YAML "), Some(Format::Yaml));
4901        assert_eq!(Format::parse("xml"), None);
4902        assert_eq!(Format::ALL.len(), 7);
4903    }
4904
4905    #[test]
4906    fn human_bytes_reads_at_scale() {
4907        assert_eq!(human_bytes(0), "0 B");
4908        assert_eq!(human_bytes(512), "512 B");
4909        assert_eq!(human_bytes(999), "999 B");
4910        assert_eq!(human_bytes(1000), "1,000 B");
4911        assert_eq!(human_bytes(1024), "1.0 KiB");
4912        assert_eq!(human_bytes(1024 * 1024 * 20), "20 MiB");
4913    }
4914
4915    #[test]
4916    fn human_counts_share_one_full_width_grouping_policy() {
4917        assert_eq!(human_count(999), "999");
4918        assert_eq!(human_count(1000), "1,000");
4919        assert_eq!(
4920            human_count_u128(u128::MAX),
4921            "340,282,366,920,938,463,463,374,607,431,768,211,455"
4922        );
4923        assert_eq!(human_age(Some(1000 * 86400 * 1_000_000_000)), "1,000d");
4924    }
4925
4926    #[test]
4927    fn bars_are_fixed_at_ten_cells_and_saturate() {
4928        assert_eq!(usage_bar(0, 100, Some(0), false, 10), "░░░░░░░░░░");
4929        assert_eq!(usage_bar(50, 100, Some(0), false, 10), "█████░░░░░");
4930        assert_eq!(usage_bar(200, 100, Some(0), false, 10), "██████████");
4931        assert!((ratio(5, 0) - 0.0).abs() < f64::EPSILON);
4932        assert_eq!(bar_cells(u64::MAX / 2, u64::MAX, 1), 0);
4933        assert_eq!(bar_cells(u64::MAX / 2 + 1, u64::MAX, 1), 1);
4934    }
4935
4936    #[test]
4937    fn human_styles_respect_exact_thresholds_and_directory_identity() {
4938        assert_eq!(styled_bytes(0, 0, true, false), "\x1b[90m0 B\x1b[0m");
4939        assert_eq!(styled_bytes(0, 0, false, false), "0 B");
4940        let gib = 1 << 30;
4941        assert_eq!(styled_bytes(gib - 1, 10, true, false), format!("{:>10}", human_bytes(gib - 1)));
4942        assert_eq!(
4943            styled_bytes(gib, 10, true, false),
4944            paint("   1.0 GiB", AnsiStyle::new().bold(), true)
4945        );
4946        assert_eq!(styled_bytes(gib, 0, true, true), paint("1.0 GiB", STYLE_DETAIL.bold(), true));
4947        assert_eq!(styled_bytes(gib, 0, false, true), "1.0 GiB");
4948        assert_eq!(percentage_cell(99, 10_000, 0, 5, true), detail("  <1%", true));
4949        assert_eq!(percentage_cell(100, 10_000, 0, 5, true), "   1%");
4950        assert_eq!(human_percentage(u64::MAX / 100, u64::MAX, 0), "<1%");
4951        assert_eq!(human_percentage(u64::MAX / 100 + 1, u64::MAX, 0), "1%");
4952        assert_eq!(human_percentage(u64::MAX / 1_000, u64::MAX, 1), "<0.1%");
4953        for name in [".", ".."] {
4954            assert_eq!(human_name(name, EntryKind::Dir, None, false), name);
4955        }
4956        assert_eq!(
4957            human_name("build", EntryKind::Dir, None, true),
4958            format!("{}{}", paint("build", STYLE_NAME, true), detail("/", true))
4959        );
4960        assert_eq!(human_name("build", EntryKind::File, None, false), "build");
4961        assert_eq!(human_name("build", EntryKind::Dir, None, false), "build/");
4962    }
4963
4964    #[test]
4965    fn only_own_ignored_directories_lose_bold_name_styling() {
4966        for ignored in [None, Some(false), Some(true)] {
4967            let style = if ignored == Some(true) { STYLE_IGNORED_NAME } else { STYLE_NAME };
4968            assert_eq!(
4969                human_name("node_modules", EntryKind::Dir, ignored, true),
4970                format!("{}{}", paint("node_modules", style, true), detail("/", true))
4971            );
4972            assert_eq!(
4973                human_name("file.rs", EntryKind::File, ignored, true),
4974                paint("file.rs", STYLE_NAME, true)
4975            );
4976            assert_eq!(human_name("node_modules", EntryKind::Dir, ignored, false), "node_modules/");
4977        }
4978    }
4979
4980    #[test]
4981    fn colored_bars_partition_selected_usage_and_keep_ten_cells() {
4982        let split = usage_bar(60, 100, Some(20), true, 10);
4983        assert_eq!(
4984            split,
4985            format!(
4986                "{}{}{}",
4987                paint("████", STYLE_BAR, true),
4988                paint("▓▓", STYLE_BAR, true),
4989                paint("░░░░", STYLE_BAR.dimmed(), true)
4990            )
4991        );
4992        assert_eq!(strip_ansi(&split).chars().count(), 10);
4993        assert_eq!(usage_bar(60, 100, Some(20), false, 10), "██████░░░░");
4994        assert_eq!(strip_ansi(&usage_bar(60, 60, Some(60), true, 10)), "▓▓▓▓▓▓▓▓▓▓");
4995        assert_eq!(
4996            usage_bar(60, 60, Some(60), true, 10),
4997            format!(
4998                "{}{}{}",
4999                paint("", STYLE_BAR, true),
5000                paint("▓▓▓▓▓▓▓▓▓▓", STYLE_BAR, true),
5001                paint("", STYLE_BAR.dimmed(), true)
5002            )
5003        );
5004        assert_eq!(strip_ansi(&usage_bar(0, 0, None, true, 10)), "░░░░░░░░░░");
5005        let mostly_ignored = usage_bar(130, 2_120, Some(96), true, 10);
5006        assert_eq!(strip_ansi(&mostly_ignored), "▓░░░░░░░░░");
5007        assert!(mostly_ignored.contains("\x1b[32m▓\x1b[0m"));
5008        assert_eq!(strip_ansi(&usage_bar(130, 2_120, Some(96), true, 20)), "▓░░░░░░░░░░░░░░░░░░░");
5009        assert_eq!(strip_ansi(&usage_bar(130, 2_120, Some(96), true, 100)).matches('▓').count(), 4);
5010    }
5011
5012    #[test]
5013    fn render_options_resize_or_remove_tree_bars_without_changing_data() {
5014        let report = fixture_for(&Query {
5015            views: vec![ViewSpec::Tree],
5016            selection: Selection {
5017                size: SizeMetric::Apparent,
5018                depth: Some(Bound::Limit(0)),
5019                ..Selection::default()
5020            },
5021            ..Query::default()
5022        });
5023        for width in [0, 10, 20] {
5024            let options = RenderOptions { color: false, bar_size: width };
5025            let text = render_with_options(&report, Format::Text, options).expect("tree");
5026            let lines = text.lines().collect::<Vec<_>>();
5027            assert_eq!(lines.len(), 2, "{width}: {text:?}");
5028            for line in &lines {
5029                if width == 0 {
5030                    assert!(line.starts_with(" 100%"), "{line:?}");
5031                } else {
5032                    assert!(line.starts_with(&"█".repeat(width)), "{line:?}");
5033                    assert!(line["█".repeat(width).len()..].starts_with("   100%"));
5034                }
5035            }
5036            assert!(lines[0].ends_with("120 B  . 2 files"), "{text:?}");
5037            assert!(lines[1].ends_with("120 B    … and 2 more files"), "{text:?}");
5038            let mut streamed = Vec::new();
5039            write_with_options(&report, Format::Text, options, &mut streamed).expect("stream");
5040            assert_eq!(streamed, text.as_bytes());
5041        }
5042        assert_eq!(
5043            render_with_options(&report, Format::Json, RenderOptions { color: true, bar_size: 20 })
5044                .expect("machine"),
5045            render(&report, Format::Json, false)
5046        );
5047        let excessive = RenderOptions { color: false, bar_size: MAX_BAR_SIZE + 1 };
5048        assert!(
5049            render_with_options(&report, Format::Text, excessive)
5050                .expect_err("tree width must be bounded")
5051                .to_string()
5052                .contains("bar_size")
5053        );
5054        let mut output = Vec::new();
5055        assert_eq!(
5056            write_with_options(&report, Format::Text, excessive, &mut output)
5057                .expect_err("streaming tree width must be bounded")
5058                .kind(),
5059            io::ErrorKind::InvalidInput
5060        );
5061        assert!(output.is_empty());
5062        let summary = fixture(&[ViewSpec::Summary]);
5063        assert_eq!(
5064            render_with_options(&summary, Format::Text, excessive).expect("no tree bar"),
5065            render(&summary, Format::Text, false)
5066        );
5067    }
5068
5069    const DEEP_RENDER_CHILD_ENV: &str = "FDU_DEEP_RENDER_CHILD";
5070    const DEEP_RENDER_DEPTH: usize = 1_024;
5071    const DEEP_REPORT_STACK_BYTES: usize = 128 * 1_024;
5072    const DEEP_RENDER_STACK_BYTES: usize = 64 * 1_024;
5073
5074    // ---- renderer tests that lived in the command line -------------------------------
5075    //
5076    // They test expansion and the three renderers, not argument handling, and they build
5077    // an index by hand -- which is why moving the CLI into its own crate surfaced them:
5078    // the fixture helpers they need are `pub(crate)` here and unreachable from there.
5079
5080    #[test]
5081    fn deep_rendering_is_stack_safe() {
5082        if std::env::var_os(DEEP_RENDER_CHILD_ENV).is_some() {
5083            run_deep_render_child();
5084            return;
5085        }
5086
5087        let started = Instant::now();
5088        let mut child = Command::new(std::env::current_exe().expect("current test executable"))
5089            .args(["--exact", DEEP_RENDER_TEST_PATH, "--nocapture"])
5090            .env(DEEP_RENDER_CHILD_ENV, "1")
5091            .stdin(Stdio::null())
5092            .stdout(Stdio::piped())
5093            .stderr(Stdio::piped())
5094            .spawn()
5095            .expect("spawn deep-render child");
5096        let stdout = drain_pipe(child.stdout.take().expect("piped stdout"));
5097        let stderr = drain_pipe(child.stderr.take().expect("piped stderr"));
5098        let status = loop {
5099            if let Some(status) = child.try_wait().expect("poll deep-render child") {
5100                break Some(status);
5101            }
5102            if started.elapsed() >= DEEP_RENDER_CHILD_TIMEOUT {
5103                break None;
5104            }
5105            std::thread::sleep(Duration::from_millis(50));
5106        };
5107        let Some(status) = status else {
5108            // `kill` is SIGKILL on Unix, which the child can neither catch nor ignore: the
5109            // hang this bounds also ignored SIGTERM. Its result can be ignored because the
5110            // child is not yet reaped, so even one that exited a moment ago is still there
5111            // to signal, and `wait` returns once it is gone.
5112            let _ = child.kill();
5113            let reaped = child.wait().expect("reap the killed deep-render child");
5114            panic!(
5115                "deep-render child still running after {DEEP_RENDER_CHILD_TIMEOUT:?}; killed at \
5116                 {:?} ({reaped}). The last `deep-render phase:` line is where it stalled.\n\
5117                 stdout:\n{}\nstderr:\n{}",
5118                started.elapsed(),
5119                stdout.join().expect("stdout reader"),
5120                stderr.join().expect("stderr reader")
5121            );
5122        };
5123        let stdout = stdout.join().expect("stdout reader");
5124        let stderr = stderr.join().expect("stderr reader");
5125        assert!(
5126            status.success(),
5127            "deep renderer failed in child process\nstdout:\n{stdout}\nstderr:\n{stderr}"
5128        );
5129
5130        // The exit code alone cannot tell "the deep render survived" from "the filter
5131        // matched nothing": libtest runs zero tests and exits 0 for a name that does not
5132        // exist, so a moved test would keep reporting success having stopped running --
5133        // which is what happened when this test moved out of `cli::tests` (fdu-rdom).
5134        assert!(
5135            stdout.contains("1 passed"),
5136            "the child must actually run the deep render, not filter it away\nstdout:\n{stdout}"
5137        );
5138    }
5139
5140    /// The child re-invocation filters on this, so it has to track the module the test
5141    /// lives in. Named once, beside the test, rather than spelled in the argument list
5142    /// where a move leaves it silently stale.
5143    const DEEP_RENDER_TEST_PATH: &str = "report_format::tests::deep_rendering_is_stack_safe";
5144
5145    /// How long the parent lets the deep-render child run before killing it.
5146    ///
5147    /// Without a bound a stuck child blocks the suite indefinitely: on 2026-09-28 one spun
5148    /// for more than 85 minutes (fdu-xsg1). Measured on Linux x86-64 debug with four
5149    /// cores, the child takes about 21 s alone and 28 s under the full parallel suite,
5150    /// nearly all of it building the 1,024-level fixture one batch at a time. Five minutes
5151    /// is ten times the loaded figure, room for a runner several times slower, and still
5152    /// turns a hang into a failure well before anyone would notice a stalled gate.
5153    const DEEP_RENDER_CHILD_TIMEOUT: Duration = Duration::from_secs(300);
5154
5155    /// Read a child's pipe to its end on a helper thread.
5156    ///
5157    /// One reader per pipe means a child that fills one pipe's buffer never blocks while
5158    /// the parent waits on the other pipe or on its exit, and what the child wrote before
5159    /// a hang is still there to report once it is killed.
5160    fn drain_pipe(mut pipe: impl io::Read + Send + 'static) -> std::thread::JoinHandle<String> {
5161        std::thread::spawn(move || {
5162            let mut bytes = Vec::new();
5163            // A read error ends the capture early; what arrived before it is still reported.
5164            let _ = pipe.read_to_end(&mut bytes);
5165            String::from_utf8_lossy(&bytes).into_owned()
5166        })
5167    }
5168
5169    fn run_deep_render_child() {
5170        // A deep tree must build and render without depth-recursive stack growth.
5171        // Windows reserves 20 KiB of a spawned thread's stack for overflow handling;
5172        // a 64 KiB reservation leaves too little dependable room for report setup in
5173        // debug builds. Keep construction bounded at 128 KiB, then test rendering and
5174        // release separately on the original 64 KiB stack.
5175        eprintln!("deep-render phase: fixture");
5176        let mut index = crate::Index::new("/fixture");
5177        let mut path = PathBuf::new();
5178        for depth in 0..DEEP_RENDER_DEPTH {
5179            path.push("d");
5180            index.apply_ok(&crate::Observation::new(vec![crate::Op::Upsert {
5181                path: path.clone(),
5182                kind: EntryKind::Dir,
5183                attrs: crate::Attrs {
5184                    mtime_ns: i64::try_from(depth).expect("fixture depth fits i64"),
5185                    ..Default::default()
5186                },
5187            }]));
5188        }
5189        index.set_initial_freshness(false);
5190
5191        let report = std::thread::Builder::new()
5192            .name("deep-report".to_string())
5193            .stack_size(DEEP_REPORT_STACK_BYTES)
5194            .spawn(move || {
5195                let query = Query {
5196                    selection: Selection {
5197                        depth: Some(Bound::All),
5198                        breadth: Some(Bound::All),
5199                        limit: Some(Bound::All),
5200                        min_share: Some(ShareThreshold::parse("0%").expect("zero share is valid")),
5201                        ..Selection::default()
5202                    },
5203                    views: vec![ViewSpec::Tree],
5204                    ..Query::default()
5205                };
5206                let provenance = Provenance {
5207                    scan_started_at: None,
5208                    generated_at: SystemTime::UNIX_EPOCH,
5209                    source: ReportSource::ColdScan,
5210                    complete: true,
5211                    errors: Vec::new(),
5212                };
5213                eprintln!("deep-render phase: request");
5214                let request = crate::test_support::read_of(&index, query);
5215                eprintln!("deep-render phase: report");
5216                let report = report(&index, &request, &provenance).expect("report");
5217                eprintln!("deep-render phase: verify tree");
5218                let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
5219                    panic!("expected a tree section with a root")
5220                };
5221                assert!(omissions.is_empty(), "the root must not be omitted");
5222                let mut nodes = 0;
5223                let mut pending = vec![root.as_ref()];
5224                while let Some(node) = pending.pop() {
5225                    nodes += 1;
5226                    assert!(node.omissions.is_empty(), "no branch may be omitted: {:?}", node.path);
5227                    pending.extend(node.children.iter());
5228                }
5229                assert_eq!(nodes, DEEP_RENDER_DEPTH + 1, "the test must reach every directory");
5230                report
5231            })
5232            .expect("spawn deep-report thread")
5233            .join()
5234            .expect("deep-report thread");
5235
5236        std::thread::Builder::new()
5237            .name("deep-render".to_string())
5238            .stack_size(DEEP_RENDER_STACK_BYTES)
5239            .spawn(move || {
5240                for format in [Format::Text, Format::Json, Format::Jsonl, Format::Yaml] {
5241                    eprintln!("deep-render phase: render {format:?}");
5242                    let rendered = render(&report, format, false);
5243                    assert!(!rendered.is_empty(), "{format:?} rendered nothing for a deep tree");
5244                    if format != Format::Text {
5245                        eprintln!("deep-render phase: stream {format:?}");
5246                        let mut streamed = Vec::new();
5247                        write(&report, format, false, &mut streamed).expect("stream deep report");
5248                        assert_eq!(streamed, rendered.as_bytes(), "{format:?} bytes differ");
5249                    }
5250                }
5251                eprintln!("deep-render phase: drop");
5252                drop(report);
5253                eprintln!("deep-render phase: complete");
5254            })
5255            .expect("spawn deep-render thread")
5256            .join()
5257            .expect("deep-render thread");
5258    }
5259
5260    /// Two names that differ only in bytes `to_string_lossy` cannot represent must stay
5261    /// distinguishable in machine output.
5262    ///
5263    /// This coverage was lost when the CLI moved to the five axes: `raw_identity_json`
5264    /// survived the rewrite, its tests did not, and the merge from PR #6 is what surfaced
5265    /// the gap. Retargeted here to the report path rather than restored to the old
5266    /// `write_json`, because the guarantee belongs to the format, not to the flag that
5267    /// used to select it.
5268    fn assert_json_preserves_raw_identity(
5269        root: PathBuf,
5270        first: &OsStr,
5271        second: &OsStr,
5272        encoding: &str,
5273        root_hex: &str,
5274        first_hex: &str,
5275        second_hex: &str,
5276    ) {
5277        // The premise: lossy rendering collapses these two into the same string, so a
5278        // consumer with only `name` cannot tell them apart.
5279        assert_eq!(first.to_string_lossy(), second.to_string_lossy());
5280
5281        let mut index = crate::Index::new(root);
5282        index.apply_ok(&crate::Observation::new(vec![
5283            crate::Op::Upsert {
5284                path: PathBuf::from(first),
5285                kind: EntryKind::File,
5286                attrs: crate::Attrs { size: 1, allocated: 1, ..Default::default() },
5287            },
5288            crate::Op::Upsert {
5289                path: PathBuf::from(second),
5290                kind: EntryKind::File,
5291                attrs: crate::Attrs { size: 1, allocated: 1, ..Default::default() },
5292            },
5293        ]));
5294        index.set_initial_freshness(false);
5295
5296        // Built directly rather than through the command line's argument struct: what is
5297        // under test is that the renderer preserves a non-UTF-8 name's raw identity, and
5298        // routing that through argument parsing tied a renderer test to a front end.
5299        let query = crate::query::Query {
5300            views: vec![ViewSpec::Files],
5301            selection: Selection { limit: Some(crate::query::Bound::All), ..Selection::default() },
5302            ..crate::query::Query::default()
5303        };
5304        let provenance = Provenance {
5305            scan_started_at: None,
5306            generated_at: std::time::UNIX_EPOCH,
5307            source: ReportSource::ColdScan,
5308            complete: true,
5309            errors: Vec::new(),
5310        };
5311        let files_report =
5312            report(&index, &crate::test_support::read_of(&index, query.clone()), &provenance)
5313                .expect("report");
5314        let rendered = render(&files_report, Format::Json, false);
5315        let mut checked = SchemaCheck::report(JsonSink::pretty(), true, true);
5316        emit_report(&mut checked, &files_report, true);
5317        assert_eq!(checked.finish(), rendered);
5318
5319        let lossy = first.to_string_lossy();
5320        assert_eq!(
5321            rendered.matches(&format!("\"{lossy}\"")).count(),
5322            2,
5323            "both names render the same lossy text: {rendered}"
5324        );
5325        assert!(
5326            rendered.contains(&format!(
5327                "\"root_raw\": {{\"encoding\": \"{encoding}\", \"hex\": \"{root_hex}\"}}"
5328            )),
5329            "{rendered}"
5330        );
5331
5332        // Pinned as the whole row rather than as a substring of it. A loose `contains`
5333        // check on the `path_raw` object alone passed while the row around it was
5334        // malformed -- the field was emitted with a duplicated separator and a newline
5335        // inside a one-line object, so the document did not parse at all. Asserting the
5336        // exact row is what makes the surrounding punctuation part of the contract.
5337        for hex in [first_hex, second_hex] {
5338            let row = format!(
5339                "{{\"path\": \"{lossy}\", \"path_raw\": {{\"encoding\": \"{encoding}\", \"hex\": \"{hex}\"}}, \
5340                 \"kind\": \"file\", \"bytes\": 1, \"allocated\": 1, \"mtime_ns\": 0, \
5341                 \"files\": null, \"dirs\": null, \"complete\": null, \"age_ns\": 0, \"ignored\": false, \"sort_value\": null, \
5342                 \"classification\": {{\"file_type\": \"unknown\", \"family\": \"unknown\", \"source\": \"unknown\", \"confidence\": \"heuristic\", \
5343                 \"flags\": {{\"generated\": false, \"vendored\": false, \"documentation\": false}}}}}}"
5344            );
5345            assert!(
5346                compact_json(&rendered).contains(&compact_json(&row)),
5347                "a name that is not valid Unicode must carry its raw bytes in a well-formed \
5348                 row.\nexpected: {row}\nrendered: {rendered}"
5349            );
5350        }
5351
5352        // Cheap structural guard against the same class of mistake anywhere else in the
5353        // document: an empty element is the signature of a separator emitted twice.
5354        assert!(
5355            !rendered.contains(", ,") && !rendered.contains(",,"),
5356            "duplicated separator in machine output: {rendered}"
5357        );
5358
5359        // The tree writer names entries too, and carried the identical defect. Pinning
5360        // only the files view would have left half the fix untested. A tree lists
5361        // directories, so the case has to be a directory whose own name is not valid
5362        // Unicode rather than the files above.
5363        let mut dirs = crate::Index::new(PathBuf::from("/tree-fixture"));
5364        dirs.apply_ok(&crate::Observation::new(vec![
5365            crate::Op::Upsert {
5366                path: PathBuf::from(first),
5367                kind: EntryKind::Dir,
5368                attrs: crate::Attrs { size: 0, allocated: 0, ..Default::default() },
5369            },
5370            crate::Op::Upsert {
5371                path: PathBuf::from(first).join("inside.txt"),
5372                kind: EntryKind::File,
5373                attrs: crate::Attrs { size: 1, allocated: 1, ..Default::default() },
5374            },
5375        ]));
5376        dirs.set_initial_freshness(false);
5377        let tree_query = crate::query::Query {
5378            views: vec![ViewSpec::Tree],
5379            selection: Selection {
5380                depth: Some(crate::query::Bound::All),
5381                limit: Some(crate::query::Bound::All),
5382                ..Selection::default()
5383            },
5384            ..crate::query::Query::default()
5385        };
5386        let tree =
5387            report(&dirs, &crate::test_support::read_of(&dirs, tree_query.clone()), &provenance)
5388                .expect("report");
5389        let tree_rendered = render(&tree, Format::Json, false);
5390        assert!(
5391            compact_json(&tree_rendered).contains(&compact_json(&format!(
5392                ", \"path_raw\": {{\"encoding\": \"{encoding}\", \"hex\": \"{first_hex}\"}}, \"kind\":"
5393            ))),
5394            "the tree view must carry raw identity in a well-formed node: {tree_rendered}"
5395        );
5396        assert!(
5397            !tree_rendered.contains(", ,") && !tree_rendered.contains(",,"),
5398            "duplicated separator in tree output: {tree_rendered}"
5399        );
5400    }
5401
5402    #[cfg(unix)]
5403    #[test]
5404    fn json_preserves_distinct_non_unicode_unix_names() {
5405        use std::ffi::OsString;
5406        use std::os::unix::ffi::OsStringExt;
5407
5408        assert_json_preserves_raw_identity(
5409            PathBuf::from(OsString::from_vec(vec![b'/', 0x80])),
5410            &OsString::from_vec(vec![b'n', 0x80]),
5411            &OsString::from_vec(vec![b'n', 0x81]),
5412            "unix-bytes",
5413            "2f80",
5414            "6e80",
5415            "6e81",
5416        );
5417    }
5418
5419    #[cfg(windows)]
5420    #[test]
5421    fn json_preserves_distinct_non_unicode_windows_names() {
5422        use std::ffi::OsString;
5423        use std::os::windows::ffi::OsStringExt;
5424
5425        assert_json_preserves_raw_identity(
5426            PathBuf::from(OsString::from_wide(&[u16::from(b'R'), u16::from(b':'), 0xd800])),
5427            &OsString::from_wide(&[u16::from(b'n'), 0xd800]),
5428            &OsString::from_wide(&[u16::from(b'n'), 0xd801]),
5429            "windows-wtf16le",
5430            "52003a0000d8",
5431            "6e0000d8",
5432            "6e0001d8",
5433        );
5434    }
5435}