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