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//! # Why these are hand-written
7//!
8//! `serde` plus a JSON crate plus a YAML crate would be three dependency additions —
9//! and the maintained-YAML question is genuinely unsettled, since `serde_yaml` is
10//! unmaintained. The schema here is small, closed, and fully known at compile time, the
11//! crate already hand-writes its JSON, and hand-writing keeps the machine formats
12//! provably free of a serializer's own opinions about key order and number formatting.
13//! Key order is fixed by the code, which is what makes the goldens byte-stable.
14
15use std::fmt::Write as _;
16use std::io;
17use std::path::Path;
18
19use anstyle::{AnsiColor, Style as AnsiStyle};
20
21use crate::classify::human_language_name;
22use crate::content::{CoverageReason, METRICS};
23use crate::control::ControlCoverage;
24use crate::emit::{Event, IoFmt, JsonSink, Scalar, Shape, Sink, YamlSink};
25use crate::engine_contract::{Coverage, EntryKind, Freshness, IssueKind, Source};
26use crate::query::{
27    FileRow, IgnoredEntries, IgnoredTally, MetricGroup, MetricRow, MetricSummary, Report,
28    ReportSource, Section, ShareMetric, SizeMetric, SummaryRow, TierState, TreeNode, TypeRow,
29    ViewSpec, format_rfc3339, format_rfc3339_nanos, pages,
30};
31
32/// The all-caps label naming which view a block of text output belongs to.
33///
34/// Bold cyan is what `cli.rs` already gives a section heading in `--help`, so a report
35/// and the help that describes it use one visual language for the same idea.
36/// View headers share the CLI's one header style; see `cli::STYLE_HEADING`.
37const STYLE_VIEW_HEADER: AnsiStyle = AnsiColor::Cyan.on_default().bold();
38
39/// Directory names in a tree, so structure reads at a glance.
40const STYLE_DIRECTORY: AnsiStyle = AnsiColor::Cyan.on_default();
41
42/// Relative-size bars in a tree.
43const STYLE_BAR: AnsiStyle = AnsiColor::Green.on_default();
44
45/// Extensions in a type breakdown.
46const STYLE_TYPE: AnsiStyle = AnsiColor::Green.on_default();
47
48/// Telemetry: what the tool did, as against what it found.
49///
50/// The same role the CLI's performance footer and display notes use, so a bound stated in
51/// a header reads as reporting rather than as data — see the styling system in `cli.rs`.
52const STYLE_TELEMETRY: AnsiStyle = AnsiColor::BrightBlack.on_default();
53
54/// Established label width for non-language metric summaries.
55const TEXT_METRIC_LABEL_WIDTH: usize = 18;
56/// Floor for the extensions view's label column.
57const TEXT_TYPE_LABEL_WIDTH: usize = 12;
58
59/// Machine-output schema identity.
60///
61/// Any change to a field's name, type, or meaning bumps this, and a golden test fails if
62/// the schema moves without it — the versioning is the promise, not the intention.
63pub const REPORT_SCHEMA: &str = "fdu.report/7";
64/// All reports now use one shape-versioned schema regardless of requested analyzers.
65pub const CONTENT_REPORT_SCHEMA: &str = REPORT_SCHEMA;
66/// Machine-output schema identity for cache status.
67///
68/// Its own identity because cache status is its own document: a fact about the cache
69/// directory rather than about a tree, which is why it is not a `Report` section. It
70/// carries the same promise as [`REPORT_SCHEMA`] and versions independently, so a change
71/// to the report shape never invalidates a cache-status consumer, or the reverse.
72///
73/// `fdu.cache/2` adds the identity of every tier a store holds: a current snapshot's
74/// `identity`, and a `content` object for the sidecar beside any snapshot, in place of
75/// `content_bytes`.
76pub const CACHE_SCHEMA: &str = "fdu.cache/2";
77
78/// How a report is serialized.
79#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
80pub enum Format {
81    /// Human-readable text.
82    #[default]
83    Text,
84    /// The bounded directory hierarchy for a list.
85    Tree,
86    /// Matching paths, one safely escaped path per line.
87    Paths,
88    /// Flat size, signed modification age, and path columns.
89    Long,
90    /// One JSON document.
91    Json,
92    /// One JSON document per line, one line per section.
93    Jsonl,
94    /// YAML.
95    Yaml,
96}
97
98/// Start one document in a multi-document stream for `format`.
99pub const fn document_start(format: Format) -> &'static str {
100    match format {
101        Format::Yaml => "---\n",
102        Format::Text
103        | Format::Tree
104        | Format::Paths
105        | Format::Long
106        | Format::Json
107        | Format::Jsonl => "",
108    }
109}
110
111impl Format {
112    /// Stable spelling used by request adapters and diagnostics.
113    pub const fn label(self) -> &'static str {
114        match self {
115            Self::Text => "text",
116            Self::Tree => "tree",
117            Self::Paths => "paths",
118            Self::Long => "long",
119            Self::Json => "json",
120            Self::Jsonl => "jsonl",
121            Self::Yaml => "yaml",
122        }
123    }
124
125    /// Whether this format is a structured serialization.
126    pub const fn is_machine(self) -> bool {
127        matches!(self, Self::Json | Self::Jsonl | Self::Yaml)
128    }
129
130    /// Parse a `--format` value.
131    pub fn parse(value: &str) -> Option<Self> {
132        match value.trim().to_ascii_lowercase().as_str() {
133            "text" => Some(Self::Text),
134            "tree" => Some(Self::Tree),
135            "paths" => Some(Self::Paths),
136            "long" => Some(Self::Long),
137            "json" => Some(Self::Json),
138            "jsonl" => Some(Self::Jsonl),
139            "yaml" => Some(Self::Yaml),
140            _ => None,
141        }
142    }
143
144    /// Every accepted spelling, for help text and error messages.
145    pub const ALL: &'static [&'static str] =
146        &["text", "tree", "paths", "long", "json", "jsonl", "yaml"];
147}
148
149/// Render a report in the requested format.
150///
151/// `color` applies to the text form only: machine output is never colourized, because a
152/// consumer parsing JSON should never have to strip escape sequences first.
153///
154/// # Errors
155///
156/// Returns an invalid-request error when Tree/Paths/Long cannot represent the stored
157/// projection. Request the desired format on the query before reading: a detached,
158/// folded tree does not retain the complete flat inventory.
159pub fn render(report: &Report, format: Format, color: bool) -> crate::Result<String> {
160    let format = checked_format(report, format)?;
161    Ok(match format {
162        Format::Text | Format::Tree => render_text(report, color),
163        Format::Paths | Format::Long => render_flat(report, format),
164        Format::Json => render_report_machine(report, true, JsonSink::pretty()),
165        Format::Jsonl => render_report_jsonl(report),
166        Format::Yaml => render_report_machine(report, true, YamlSink::new()),
167    })
168}
169
170/// One path for a line-oriented listing: control characters become escapes so a row stays
171/// one row, and everything else, the separator included, is written as it is.
172///
173/// Only control characters. This once escaped `\` as well, and on Windows the separator
174/// *is* `\`, so `--format paths` printed `c\\target`, a path that does not exist, and the
175/// golden that covered it matched the doubled separator instead of failing on it. The
176/// price of not escaping it is that a name holding a literal backslash followed by a
177/// letter is ambiguous with an escape; the listing is lossy by contract, and a consumer
178/// that needs byte identity reads JSON's `path_raw`.
179fn flat_path(path: &Path) -> String {
180    path.to_string_lossy()
181        .chars()
182        .flat_map(|c| if c.is_control() { c.escape_default().collect::<Vec<_>>() } else { vec![c] })
183        .collect()
184}
185
186/// A compact signed duration. Exact nanoseconds remain available in machine output.
187fn human_age(age: Option<i128>) -> String {
188    let Some(age) = age else { return "unknown".to_string() };
189    let seconds = age.unsigned_abs() / 1_000_000_000;
190    let (amount, unit) = if seconds >= 86400 {
191        (seconds / 86400, "d")
192    } else if seconds >= 3600 {
193        (seconds / 3600, "h")
194    } else if seconds >= 60 {
195        (seconds / 60, "m")
196    } else {
197        (seconds, "s")
198    };
199    format!("{}{amount}{unit}", if age < 0 { "-" } else { "" })
200}
201
202/// Notes excluded from flat stdout, for a frontend's diagnostic stream.
203pub fn flat_diagnostics(report: &Report) -> Vec<String> {
204    let mut notes = report.notes.clone();
205    if report.provenance.source == ReportSource::CacheOnly {
206        notes.push("cache-only result: retained contents have not been revalidated".into());
207    }
208    if !report.status.complete || report.provenance.freshness != Freshness::Fresh {
209        notes.push(format!(
210            "result freshness: {}; complete: {}",
211            freshness_label(report.provenance.freshness),
212            report.status.complete
213        ));
214    }
215    if let Some(depth) = report.scope.max_depth {
216        notes
217            .push(format!("scan scope limited to depth {depth}; subtree metrics cover this scope"));
218    }
219    for section in &report.sections {
220        let bound = bound_note(section);
221        if !bound.is_empty() {
222            notes.push(bound.trim().to_string());
223        }
224    }
225    notes
226}
227
228fn render_flat(report: &Report, format: Format) -> String {
229    let mut out = String::new();
230    for section in &report.sections {
231        if let Section::Files { rows, .. } = section {
232            for row in rows {
233                if format == Format::Long {
234                    let _ = writeln!(
235                        out,
236                        "{:>10} {:>8} {}",
237                        human_bytes(pick(report.size, row.bytes, row.allocated)),
238                        human_age(row.age_ns),
239                        flat_path(&row.path)
240                    );
241                } else {
242                    let _ = writeln!(out, "{}", flat_path(&row.path));
243                }
244            }
245        }
246    }
247    out
248}
249
250fn checked_format(report: &Report, format: Format) -> crate::Result<Format> {
251    let format = if format == Format::Text { report.format } else { format };
252    let valid = match format {
253        Format::Paths | Format::Long => {
254            report.sections.len() == 1 && matches!(report.sections[0], Section::Files { .. })
255        }
256        Format::Tree => {
257            report.sections.len() == 1 && matches!(report.sections[0], Section::Tree { .. })
258        }
259        Format::Text | Format::Json | Format::Jsonl | Format::Yaml => true,
260    };
261    if !valid {
262        return Err(crate::Error::InvalidRequest(crate::query::Rejection::new(format.label(),
263            "incompatible with this report projection; request the desired format when building the query (a folded tree cannot become a complete flat list)").on("format")));
264    }
265    Ok(format)
266}
267
268/// Write a report directly to an output stream.
269///
270/// Machine formats retain only serializer depth while walking the report. Text remains a
271/// presentation renderer and is written after it is formatted.
272pub fn write(
273    report: &Report,
274    format: Format,
275    color: bool,
276    out: &mut dyn io::Write,
277) -> io::Result<()> {
278    let format = checked_format(report, format)
279        .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
280    match format {
281        Format::Text | Format::Tree => out.write_all(render_text(report, color).as_bytes()),
282        Format::Paths | Format::Long => out.write_all(render_flat(report, format).as_bytes()),
283        Format::Json => write_report_machine(report, true, JsonSink::pretty_to(out)),
284        Format::Jsonl => write_report_jsonl(report, out),
285        Format::Yaml => write_report_machine(report, true, YamlSink::to(out)),
286    }
287}
288
289fn render_report_machine(
290    report: &Report,
291    with_sections: bool,
292    mut sink: impl Sink<Output = String>,
293) -> String {
294    emit_report(&mut sink, report, with_sections);
295    sink.finish()
296}
297
298fn write_report_machine<'a>(
299    report: &Report,
300    with_sections: bool,
301    mut sink: impl Sink<Output = IoFmt<'a>>,
302) -> io::Result<()> {
303    emit_report(&mut sink, report, with_sections);
304    sink.finish().finish()
305}
306
307fn render_report_jsonl(report: &Report) -> String {
308    let mut sink = JsonSink::line();
309    emit_report(&mut sink, report, false);
310    let mut out = sink.finish();
311    out.push('\n');
312    for section in &report.sections {
313        let mut sink = JsonSink::line();
314        emit_section(&mut sink, section);
315        out.push_str(&sink.finish());
316        out.push('\n');
317    }
318    out
319}
320
321fn write_report_jsonl(report: &Report, out: &mut dyn io::Write) -> io::Result<()> {
322    let mut sink = JsonSink::line_to(out);
323    emit_report(&mut sink, report, false);
324    sink.finish().finish()?;
325    out.write_all(b"\n")?;
326    for section in &report.sections {
327        let mut sink = JsonSink::line_to(out);
328        emit_section(&mut sink, section);
329        sink.finish().finish()?;
330        out.write_all(b"\n")?;
331    }
332    Ok(())
333}
334
335fn emit_field<S: Sink>(sink: &mut S, field: Field, condition: bool, emit: impl FnOnce(&mut S)) {
336    let present = match field.presence {
337        Presence::Always | Presence::Nullable => true,
338        Presence::WhenAnalyzer(_) => {
339            panic!("an analyzer-owned field must use emit_analyzer_field")
340        }
341        Presence::WhenLossy | Presence::WhenSet => condition,
342    };
343    if present {
344        sink.event(Event::Key(field.name));
345        emit(sink);
346    }
347}
348
349fn emit_analyzer_field<S: Sink>(
350    sink: &mut S,
351    requested: crate::content::AnalysisSet,
352    field: Field,
353    emit: impl FnOnce(&mut S),
354) {
355    let Presence::WhenAnalyzer(owner) = field.presence else {
356        panic!("an analyzer field must declare its owning unit");
357    };
358    if requested.contains(owner) {
359        sink.event(Event::Key(field.name));
360        emit(sink);
361    }
362}
363
364fn emit_scalar(sink: &mut impl Sink, value: Scalar<'_>) {
365    sink.event(Event::Scalar(value));
366}
367
368fn emit_report(sink: &mut impl Sink, report: &Report, with_sections: bool) {
369    sink.event(Event::BeginMap(Shape::Block));
370    emit_field(sink, REPORT_FIELDS.schema, true, |sink| {
371        emit_scalar(sink, Scalar::Str(REPORT_SCHEMA));
372    });
373    let generator = generator();
374    emit_field(sink, REPORT_FIELDS.generator, true, |sink| {
375        emit_scalar(sink, Scalar::Str(&generator));
376    });
377    let root = report.root.to_string_lossy();
378    emit_field(sink, REPORT_FIELDS.root, true, |sink| {
379        emit_scalar(sink, Scalar::Str(&root));
380    });
381    emit_raw_identity(sink, REPORT_FIELDS.root_raw.name, &report.root);
382    emit_field(sink, REPORT_FIELDS.age_reference_ns, true, |sink| match report.age_reference_ns {
383        Some(value) => emit_scalar(sink, Scalar::I64(value)),
384        None => emit_scalar(sink, Scalar::Null),
385    });
386    emit_field(sink, REPORT_FIELDS.request, true, |sink| emit_request(sink, report));
387    emit_field(sink, REPORT_FIELDS.status, true, |sink| emit_status(sink, report));
388    emit_field(sink, REPORT_FIELDS.provenance, true, |sink| emit_provenance(sink, report));
389    emit_field(sink, REPORT_FIELDS.ignore_rules, true, |sink| {
390        emit_ignore_rules(sink, &report.ignore_rules);
391    });
392    emit_field(sink, REPORT_FIELDS.analysis, true, |sink| {
393        emit_analysis(sink, report.analysis.as_ref());
394    });
395    emit_field(sink, REPORT_FIELDS.reports, with_sections, |sink| {
396        sink.event(Event::BeginSeq(Shape::Block));
397        for section in &report.sections {
398            emit_section(sink, section);
399        }
400        sink.event(Event::EndSeq);
401    });
402    sink.event(Event::EndMap);
403}
404
405fn emit_request(sink: &mut impl Sink, report: &Report) {
406    sink.event(Event::BeginMap(Shape::Block));
407    emit_field(sink, Field::always("scope"), true, |sink| {
408        sink.event(Event::BeginMap(Shape::Block));
409        emit_field(sink, Field::nullable("max_depth"), true, |sink| {
410            emit_optional_usize(sink, report.scope.max_depth);
411        });
412        emit_bool_field(sink, "follow_symlinks", report.scope.follow_symlinks);
413        emit_bool_field(sink, "one_filesystem", report.scope.one_filesystem);
414        emit_bool_field(sink, "exclude_special", report.scope.exclude_special);
415        emit_bool_field(sink, "read_controls", report.scope.observes_controls());
416        sink.event(Event::EndMap);
417    });
418    emit_field(sink, Field::always("analyze"), true, |sink| {
419        sink.event(Event::BeginSeq(Shape::Inline));
420        for label in analysis_set_labels(report.requested_analysis) {
421            emit_scalar(sink, Scalar::Str(label));
422        }
423        sink.event(Event::EndSeq);
424    });
425    emit_str_field(sink, "size", report.size.label());
426    emit_field(sink, Field::always("views"), true, |sink| {
427        sink.event(Event::BeginSeq(Shape::Inline));
428        for view in &report.requested_views {
429            emit_scalar(sink, Scalar::Str(view.label()));
430        }
431        sink.event(Event::EndSeq);
432    });
433    emit_field(sink, Field::always("omitted_views"), true, |sink| {
434        sink.event(Event::BeginSeq(Shape::Inline));
435        for view in &report.omitted_views {
436            emit_scalar(sink, Scalar::Str(view.label()));
437        }
438        sink.event(Event::EndSeq);
439    });
440    sink.event(Event::EndMap);
441}
442
443fn emit_status(sink: &mut impl Sink, report: &Report) {
444    sink.event(Event::BeginMap(Shape::Block));
445    emit_bool_field(sink, "complete", report.status.complete);
446    emit_field(sink, Field::always("coverage"), true, |sink| {
447        sink.event(Event::BeginMap(Shape::Inline));
448        match report.status.coverage {
449            Coverage::Complete => emit_str_field(sink, "kind", "complete"),
450            Coverage::Partial(reason) => {
451                emit_str_field(sink, "kind", "partial");
452                emit_str_field(sink, "reason", structural_coverage_label(reason));
453            }
454        }
455        sink.event(Event::EndMap);
456    });
457    emit_field(sink, Field::always("errors"), true, |sink| {
458        sink.event(Event::BeginSeq(Shape::Block));
459        for error in &report.status.errors {
460            sink.event(Event::BeginMap(Shape::Block));
461            emit_field(sink, Field::when_set("path"), error.path.is_some(), |sink| {
462                let path = error.path.as_ref().expect("present error path");
463                let display = path.to_string_lossy();
464                emit_scalar(sink, Scalar::Str(&display));
465            });
466            if let Some(path) = &error.path {
467                emit_raw_identity(sink, "path_raw", path);
468            }
469            emit_str_field(sink, "kind", issue_kind_label(error.kind));
470            emit_str_field(sink, "message", &error.message);
471            emit_field(sink, Field::when_set("os_error"), error.os_error.is_some(), |sink| {
472                emit_scalar(sink, Scalar::I64(i64::from(error.os_error.expect("present errno"))));
473            });
474            sink.event(Event::EndMap);
475        }
476        sink.event(Event::EndSeq);
477    });
478    emit_u64_field(sink, "errors_omitted", report.status.errors_omitted);
479    sink.event(Event::EndMap);
480}
481
482fn emit_provenance(sink: &mut impl Sink, report: &Report) {
483    sink.event(Event::BeginMap(Shape::Block));
484    emit_str_field(sink, "source", source_label(report.provenance.source));
485    emit_str_field(sink, "freshness", freshness_label(report.provenance.freshness));
486    emit_field(sink, Field::nullable("scan_started_at"), true, |sink| {
487        if let Some(at) = report.provenance.scan_started_at {
488            let value = format_rfc3339(at);
489            emit_scalar(sink, Scalar::Str(&value));
490        } else {
491            emit_scalar(sink, Scalar::Null);
492        }
493    });
494    let generated_at = format_rfc3339(report.provenance.generated_at);
495    emit_str_field(sink, "generated_at", &generated_at);
496    emit_field(sink, Field::always("tiers"), true, |sink| {
497        sink.event(Event::BeginMap(Shape::Block));
498        emit_field(sink, Field::always("entries"), true, |sink| {
499            emit_tier_state(sink, report.provenance.tiers.entries);
500        });
501        emit_field(sink, Field::nullable("content"), true, |sink| {
502            if let Some(content) = report.provenance.tiers.content {
503                emit_tier_state(sink, content);
504            } else {
505                emit_scalar(sink, Scalar::Null);
506            }
507        });
508        sink.event(Event::EndMap);
509    });
510    sink.event(Event::EndMap);
511}
512
513fn emit_tier_state(sink: &mut impl Sink, tier: TierState) {
514    sink.event(Event::BeginMap(Shape::Inline));
515    emit_str_field(sink, "source", tier_source_label(tier.source));
516    emit_str_field(sink, "freshness", freshness_label(tier.freshness));
517    emit_field(sink, Field::nullable("observed_at_ns"), true, |sink| match tier.observed_at_ns {
518        Some(value) => emit_scalar(sink, Scalar::I64(value)),
519        None => emit_scalar(sink, Scalar::Null),
520    });
521    sink.event(Event::EndMap);
522}
523
524fn emit_ignore_rules(sink: &mut impl Sink, rules: &ControlCoverage) {
525    let ControlCoverage::Observed(observed) = rules else {
526        emit_scalar(sink, Scalar::Null);
527        return;
528    };
529    sink.event(Event::BeginMap(Shape::Block));
530    emit_field(sink, Field::always("limits"), true, |sink| {
531        sink.event(Event::BeginMap(Shape::Inline));
532        emit_field(sink, Field::nullable("budget"), true, |sink| {
533            emit_optional_usize(sink, observed.limits.budget);
534        });
535        emit_field(sink, Field::nullable("line_limit"), true, |sink| {
536            emit_optional_usize(sink, observed.limits.line_limit);
537        });
538        sink.event(Event::EndMap);
539    });
540    emit_u64_field(sink, "applied", observed.applied);
541    emit_u64_field(sink, "refused", observed.refused);
542    emit_field(sink, Field::always("refusals"), true, |sink| {
543        sink.event(Event::BeginSeq(Shape::Block));
544        for refusal in &observed.refusals {
545            sink.event(Event::BeginMap(Shape::Block));
546            emit_path_fields(sink, &refusal.path);
547            emit_str_field(sink, "reason", refusal.reason.label());
548            sink.event(Event::EndMap);
549        }
550        sink.event(Event::EndSeq);
551    });
552    sink.event(Event::EndMap);
553}
554
555fn emit_analysis(sink: &mut impl Sink, analysis: Option<&crate::query::ContentReportMetadata>) {
556    let Some(analysis) = analysis else {
557        emit_scalar(sink, Scalar::Null);
558        return;
559    };
560    sink.event(Event::BeginMap(Shape::Block));
561    emit_field(sink, Field::always("analyze"), true, |sink| {
562        sink.event(Event::BeginSeq(Shape::Inline));
563        for label in analysis_set_labels(analysis.profile) {
564            emit_scalar(sink, Scalar::Str(label));
565        }
566        sink.event(Event::EndSeq);
567    });
568    emit_u64_field(sink, "type_rules_fingerprint", analysis.provenance.type_rules_fingerprint);
569    emit_u64_field(sink, "options_fingerprint", analysis.provenance.options_fingerprint.0);
570    emit_field(sink, Field::always("analyzers"), true, |sink| {
571        sink.event(Event::BeginSeq(Shape::Block));
572        for (id, version) in &analysis.provenance.analyzers {
573            sink.event(Event::BeginMap(Shape::Inline));
574            emit_str_field(sink, "id", id.0);
575            emit_u64_field(sink, "version", u64::from(version.0));
576            sink.event(Event::EndMap);
577        }
578        sink.event(Event::EndSeq);
579    });
580    sink.event(Event::EndMap);
581}
582
583fn emit_optional_usize(sink: &mut impl Sink, value: Option<usize>) {
584    match value {
585        Some(value) => emit_scalar(sink, Scalar::U64(value as u64)),
586        None => emit_scalar(sink, Scalar::Null),
587    }
588}
589
590fn emit_str_field(sink: &mut impl Sink, name: &'static str, value: &str) {
591    emit_field(sink, Field::always(name), true, |sink| {
592        emit_scalar(sink, Scalar::Str(value));
593    });
594}
595
596fn emit_u64_field(sink: &mut impl Sink, name: &'static str, value: u64) {
597    emit_field(sink, Field::always(name), true, |sink| {
598        emit_scalar(sink, Scalar::U64(value));
599    });
600}
601
602fn emit_bool_field(sink: &mut impl Sink, name: &'static str, value: bool) {
603    emit_field(sink, Field::always(name), true, |sink| {
604        emit_scalar(sink, Scalar::Bool(value));
605    });
606}
607
608fn emit_i64_field(sink: &mut impl Sink, name: &'static str, value: i64) {
609    emit_field(sink, Field::always(name), true, |sink| {
610        emit_scalar(sink, Scalar::I64(value));
611    });
612}
613
614fn emit_raw_identity(sink: &mut impl Sink, name: &'static str, path: &Path) {
615    let raw = raw_os_identity(path.as_os_str());
616    emit_field(sink, Field::when_lossy(name), raw.is_some(), |sink| {
617        let (encoding, hex) = raw.expect("lossy field predicate checked the raw identity");
618        sink.event(Event::BeginMap(Shape::Inline));
619        emit_str_field(sink, "encoding", encoding);
620        emit_str_field(sink, "hex", &hex);
621        sink.event(Event::EndMap);
622    });
623}
624
625fn emit_path_fields(sink: &mut impl Sink, path: &Path) {
626    let lossy = path.to_string_lossy();
627    emit_str_field(sink, "path", &lossy);
628    emit_raw_identity(sink, "path_raw", path);
629}
630
631fn emit_section(sink: &mut impl Sink, section: &Section) {
632    sink.event(Event::BeginMap(Shape::Block));
633    emit_str_field(sink, "view", section.view().label());
634    match section {
635        Section::Tree { root, .. } => {
636            emit_field(sink, Field::always("tree"), true, |sink| emit_tree(sink, root));
637        }
638        Section::Extensions { rows, total } => {
639            emit_bound_field(sink, rows.len(), *total);
640            emit_field(sink, Field::always("extensions"), true, |sink| {
641                sink.event(Event::BeginSeq(Shape::Block));
642                for row in rows {
643                    sink.event(Event::BeginMap(Shape::Block));
644                    emit_str_field(sink, "extension", &row.extension);
645                    emit_u64_field(sink, "files", row.files);
646                    emit_u64_field(sink, "bytes", row.bytes);
647                    emit_u64_field(sink, "allocated", row.allocated);
648                    emit_field(sink, Field::nullable("ignored"), true, |sink| {
649                        emit_ignored(sink, row.ignored, false);
650                    });
651                    sink.event(Event::EndMap);
652                }
653                sink.event(Event::EndSeq);
654            });
655        }
656        Section::Metrics { summary, .. } => {
657            emit_field(sink, Field::always("metrics"), true, |sink| {
658                emit_metric_summary(sink, summary);
659            });
660        }
661        Section::Files { rows, total, .. } => {
662            emit_bound_field(sink, rows.len(), *total);
663            emit_field(sink, Field::always("files"), true, |sink| {
664                sink.event(Event::BeginSeq(Shape::Block));
665                for row in rows {
666                    emit_file_row(sink, row);
667                }
668                sink.event(Event::EndSeq);
669            });
670        }
671        Section::Summary(row) => {
672            emit_field(sink, Field::always("summary"), true, |sink| {
673                emit_summary_row(sink, row);
674            });
675        }
676    }
677    sink.event(Event::EndMap);
678}
679
680fn emit_bound_field(sink: &mut impl Sink, shown: usize, total: usize) {
681    emit_field(sink, Field::nullable("bound"), true, |sink| {
682        if shown >= total {
683            emit_scalar(sink, Scalar::Null);
684        } else {
685            sink.event(Event::BeginMap(Shape::Inline));
686            emit_u64_field(sink, "shown", shown as u64);
687            emit_u64_field(sink, "total", total as u64);
688            sink.event(Event::EndMap);
689        }
690    });
691}
692
693fn emit_file_row(sink: &mut impl Sink, row: &FileRow) {
694    sink.event(Event::BeginMap(Shape::Block));
695    emit_path_fields(sink, &row.path);
696    emit_str_field(sink, "kind", kind_label(row.kind));
697    emit_u64_field(sink, "bytes", row.bytes);
698    emit_u64_field(sink, "allocated", row.allocated);
699    emit_i64_field(sink, "mtime_ns", row.mtime_ns);
700    for (name, value) in [("files", row.files), ("dirs", row.dirs)] {
701        emit_field(sink, Field::nullable(name), true, |sink| match value {
702            Some(value) => emit_scalar(sink, Scalar::U64(value)),
703            None => emit_scalar(sink, Scalar::Null),
704        });
705    }
706    emit_field(sink, Field::nullable("complete"), true, |sink| match row.complete {
707        Some(value) => emit_scalar(sink, Scalar::Bool(value)),
708        None => emit_scalar(sink, Scalar::Null),
709    });
710    emit_field(sink, Field::nullable("age_ns"), true, |sink| match row.age_ns {
711        Some(value) => emit_scalar(sink, Scalar::I128(value)),
712        None => emit_scalar(sink, Scalar::Null),
713    });
714
715    emit_field(sink, Field::nullable("ignored"), true, |sink| match row.ignored {
716        Some(value) => emit_scalar(sink, Scalar::Bool(value)),
717        None => emit_scalar(sink, Scalar::Null),
718    });
719    sink.event(Event::EndMap);
720}
721
722fn emit_summary_row(sink: &mut impl Sink, row: &SummaryRow) {
723    sink.event(Event::BeginMap(Shape::Block));
724    emit_u64_field(sink, "files", row.files);
725    emit_u64_field(sink, "dirs", row.dirs);
726    emit_u64_field(sink, "bytes", row.bytes);
727    emit_u64_field(sink, "allocated", row.allocated);
728    emit_field(sink, Field::nullable("ignored"), true, |sink| {
729        emit_ignored(sink, row.ignored, true);
730    });
731    emit_field(sink, Field::nullable("newest_mtime_ns"), true, |sink| match row.newest_mtime_ns {
732        Some(value) => emit_scalar(sink, Scalar::I64(value)),
733        None => emit_scalar(sink, Scalar::Null),
734    });
735    sink.event(Event::EndMap);
736}
737
738fn emit_ignored(sink: &mut impl Sink, ignored: Option<IgnoredTally>, with_dirs: bool) {
739    let Some(ignored) = ignored else {
740        emit_scalar(sink, Scalar::Null);
741        return;
742    };
743    sink.event(Event::BeginMap(Shape::Inline));
744    emit_u64_field(sink, "files", ignored.files);
745    if with_dirs {
746        emit_u64_field(sink, "dirs", ignored.dirs);
747    }
748    emit_u64_field(sink, "bytes", ignored.bytes);
749    emit_u64_field(sink, "allocated", ignored.allocated);
750    sink.event(Event::EndMap);
751}
752
753fn emit_metric_summary(sink: &mut impl Sink, summary: &MetricSummary) {
754    sink.event(Event::BeginMap(Shape::Block));
755    emit_str_field(sink, "group", metric_group_label(summary.group));
756    emit_str_field(sink, "share_metric", summary.share_metric.as_str());
757    emit_bound_field(sink, summary.rows.len(), summary.total_rows);
758    emit_field(sink, Field::always("total"), true, |sink| {
759        emit_metric_row(sink, &summary.total, summary.words_per_page);
760    });
761    emit_field(sink, Field::always("rows"), true, |sink| {
762        sink.event(Event::BeginSeq(Shape::Block));
763        for row in &summary.rows {
764            emit_metric_row(sink, row, summary.words_per_page);
765        }
766        sink.event(Event::EndSeq);
767    });
768    sink.event(Event::EndMap);
769}
770
771fn emit_metric_row(sink: &mut impl Sink, row: &MetricRow, words_per_page: u64) {
772    sink.event(Event::BeginMap(Shape::Block));
773    emit_str_field(sink, "id", &row.id);
774    emit_str_field(sink, "family", row.family.as_str());
775    emit_u64_field(sink, "files", row.files);
776    emit_u64_field(sink, "bytes", row.bytes);
777    emit_u64_field(sink, "allocated", row.allocated);
778    emit_field(sink, Field::always("share"), true, |sink| {
779        sink.event(Event::BeginMap(Shape::Inline));
780        emit_u64_field(sink, "numerator", row.share.numerator);
781        emit_u64_field(sink, "denominator", row.share.denominator);
782        sink.event(Event::EndMap);
783    });
784    emit_field(sink, Field::always("metrics"), true, |sink| {
785        emit_metric_values(sink, row);
786    });
787    emit_field(sink, Field::always("coverage"), true, |sink| {
788        sink.event(Event::BeginMap(Shape::Block));
789        emit_analyzer_field(
790            sink,
791            row.analysis,
792            Field::when_analyzer("lines", crate::content::AnalysisSet::LINES_ONLY),
793            |sink| emit_coverage_map(sink, &row.lines_coverage),
794        );
795        emit_analyzer_field(
796            sink,
797            row.analysis,
798            Field::when_analyzer("code", crate::content::AnalysisSet::CODE_ONLY),
799            |sink| emit_coverage_map(sink, row.code_coverage.as_ref().expect("code requested")),
800        );
801        emit_analyzer_field(
802            sink,
803            row.analysis,
804            Field::when_analyzer("words", crate::content::AnalysisSet::WORDS_ONLY),
805            |sink| emit_coverage_map(sink, row.words_coverage.as_ref().expect("words requested")),
806        );
807        sink.event(Event::EndMap);
808    });
809    emit_field(sink, Field::always("detection"), true, |sink| {
810        emit_detection(sink, row);
811    });
812    emit_analyzer_field(
813        sink,
814        row.analysis,
815        Field::when_analyzer("pages", crate::content::AnalysisSet::WORDS_ONLY),
816        |sink| {
817            let page = pages(row, words_per_page).expect("words request has page inputs");
818            sink.event(Event::BeginMap(Shape::Inline));
819            emit_u64_field(sink, "words", page.words);
820            emit_u64_field(sink, "words_per_page", page.words_per_page);
821            sink.event(Event::EndMap);
822        },
823    );
824    sink.event(Event::EndMap);
825}
826
827fn emit_metric_values(sink: &mut impl Sink, row: &MetricRow) {
828    sink.event(Event::BeginMap(Shape::Inline));
829    for metric in METRICS {
830        emit_analyzer_field(
831            sink,
832            row.analysis,
833            Field::when_analyzer(metric.name, metric.owner),
834            |sink| {
835                emit_scalar(
836                    sink,
837                    Scalar::U64(row.metric_value(metric).expect("metric owner requested")),
838                );
839            },
840        );
841    }
842    sink.event(Event::EndMap);
843}
844
845fn emit_coverage_map(
846    sink: &mut impl Sink,
847    coverage: &std::collections::BTreeMap<CoverageReason, u64>,
848) {
849    sink.event(Event::BeginMap(Shape::Inline));
850    for (reason, count) in coverage {
851        emit_u64_field(sink, coverage_label(*reason), *count);
852    }
853    sink.event(Event::EndMap);
854}
855
856fn emit_detection(sink: &mut impl Sink, row: &MetricRow) {
857    sink.event(Event::BeginMap(Shape::Block));
858    emit_field(sink, Field::always("sources"), true, |sink| {
859        sink.event(Event::BeginMap(Shape::Inline));
860        for (source, count) in &row.detection_sources {
861            emit_u64_field(sink, source.as_str(), *count);
862        }
863        sink.event(Event::EndMap);
864    });
865    emit_field(sink, Field::always("confidence"), true, |sink| {
866        sink.event(Event::BeginMap(Shape::Inline));
867        for (level, count) in &row.detection_confidence {
868            emit_u64_field(sink, level.as_str(), *count);
869        }
870        sink.event(Event::EndMap);
871    });
872    emit_field(sink, Field::always("flags"), true, |sink| {
873        sink.event(Event::BeginMap(Shape::Inline));
874        emit_u64_field(sink, "generated", row.generated_files);
875        emit_u64_field(sink, "vendored", row.vendored_files);
876        emit_u64_field(sink, "documentation", row.documentation_files);
877        sink.event(Event::EndMap);
878    });
879    sink.event(Event::EndMap);
880}
881
882fn emit_tree(sink: &mut impl Sink, root: &TreeNode) {
883    enum Step<'a> {
884        Node(&'a TreeNode),
885        Children(std::slice::Iter<'a, TreeNode>),
886        EndMap,
887        EndSeq,
888    }
889    let mut stack = vec![Step::Node(root)];
890    while let Some(step) = stack.pop() {
891        match step {
892            Step::Node(node) => {
893                sink.event(Event::BeginMap(Shape::Block));
894                emit_str_field(sink, "name", &node.name);
895                emit_path_fields(sink, &node.path);
896                emit_str_field(sink, "kind", kind_label(node.kind));
897                emit_u64_field(sink, "bytes", node.bytes);
898                emit_u64_field(sink, "allocated", node.allocated);
899                emit_u64_field(sink, "files", node.files);
900                emit_u64_field(sink, "dirs", node.dirs);
901                emit_field(sink, Field::nullable("ignored"), true, |sink| {
902                    emit_ignored(sink, node.ignored, true);
903                });
904                emit_field(sink, Field::nullable("newest_mtime_ns"), true, |sink| {
905                    match node.newest_mtime_ns {
906                        Some(value) => emit_scalar(sink, Scalar::I64(value)),
907                        None => emit_scalar(sink, Scalar::Null),
908                    }
909                });
910                emit_field(sink, Field::always("truncated"), true, |sink| {
911                    emit_scalar(sink, Scalar::Bool(node.truncated));
912                });
913                sink.event(Event::Key("children"));
914                sink.event(Event::BeginSeq(Shape::Block));
915                stack.push(Step::EndMap);
916                stack.push(Step::EndSeq);
917                stack.push(Step::Children(node.children.iter()));
918            }
919            Step::Children(mut children) => {
920                if let Some(child) = children.next() {
921                    stack.push(Step::Children(children));
922                    stack.push(Step::Node(child));
923                }
924            }
925            Step::EndMap => sink.event(Event::EndMap),
926            Step::EndSeq => sink.event(Event::EndSeq),
927        }
928    }
929}
930
931/// The report envelope's ordered field and presence contract.
932struct ReportFields {
933    schema: Field,
934    generator: Field,
935    root: Field,
936    root_raw: Field,
937    request: Field,
938    status: Field,
939    provenance: Field,
940    ignore_rules: Field,
941    analysis: Field,
942    reports: Field,
943    age_reference_ns: Field,
944}
945
946const REPORT_FIELDS: ReportFields = ReportFields {
947    schema: Field::always("schema"),
948    generator: Field::always("generator"),
949    root: Field::always("root"),
950    root_raw: Field::when_lossy("root_raw"),
951    request: Field::always("request"),
952    status: Field::always("status"),
953    provenance: Field::always("provenance"),
954    ignore_rules: Field::always("ignore_rules"),
955    analysis: Field::nullable("analysis"),
956    reports: Field::when_set("reports"),
957    age_reference_ns: Field::nullable("age_reference_ns"),
958};
959
960/// Why a field is present in a wire document.
961#[derive(Clone, Copy, Debug, Eq, PartialEq)]
962enum Presence {
963    Always,
964    Nullable,
965    WhenLossy,
966    WhenAnalyzer(crate::content::AnalysisSet),
967    WhenSet,
968}
969
970/// One declared field in a machine-output schema.
971#[derive(Clone, Copy, Debug)]
972struct Field {
973    name: &'static str,
974    presence: Presence,
975}
976
977impl Field {
978    const fn always(name: &'static str) -> Self {
979        Self { name, presence: Presence::Always }
980    }
981
982    const fn nullable(name: &'static str) -> Self {
983        Self { name, presence: Presence::Nullable }
984    }
985
986    const fn when_lossy(name: &'static str) -> Self {
987        Self { name, presence: Presence::WhenLossy }
988    }
989
990    const fn when_analyzer(name: &'static str, analysis: crate::content::AnalysisSet) -> Self {
991        Self { name, presence: Presence::WhenAnalyzer(analysis) }
992    }
993
994    const fn when_set(name: &'static str) -> Self {
995        Self { name, presence: Presence::WhenSet }
996    }
997}
998
999/// Wrap text in a style when colour is on.
1000fn paint(text: &str, style: AnsiStyle, color: bool) -> String {
1001    if color { format!("{style}{text}{style:#}") } else { text.to_string() }
1002}
1003
1004// ---- text ----
1005
1006/// Render the human-facing form.
1007///
1008/// A multi-view report introduces each section with an all-caps header naming its view,
1009/// blocks separated by a blank line. Machine formats already carry a `view` field on
1010/// every report, so text was the only format that lost the labelling: several tables of
1011/// similar-looking rows arrived concatenated, and the reader had to work out which view
1012/// each block came from by remembering the order they were requested in.
1013///
1014/// A single-view report is left bare, which is what keeps `fdu --view files` a listing
1015/// of paths and nothing else — the property behind `fdu --view files | xargs` — and
1016/// keeps the default one-view report exactly as it was. One block needs no label to be
1017/// unambiguous, so the header appears precisely when it disambiguates something.
1018fn render_text(report: &Report, color: bool) -> String {
1019    let mut out = String::new();
1020    let headed = report.sections.len() > 1;
1021    for (index, section) in report.sections.iter().enumerate() {
1022        if index > 0 {
1023            out.push('\n');
1024        }
1025        let bound = bound_note(section);
1026        if headed {
1027            let _ = writeln!(
1028                out,
1029                "{}{}",
1030                paint(view_header(section.view()), STYLE_VIEW_HEADER, color),
1031                paint(&bound, STYLE_TELEMETRY, color),
1032            );
1033        } else if !bound.is_empty() {
1034            // A single-view report has no header, and that is precisely the shape
1035            // `fdu --view largest` produces — so the bound gets its own line rather than
1036            // riding on a header that is not there.
1037            let _ = writeln!(out, "{}", paint(bound.trim_start(), STYLE_TELEMETRY, color));
1038        }
1039        match section {
1040            Section::Tree { root, .. } => {
1041                render_text_tree(&mut out, root, report.size, report.ignored_entries, color);
1042            }
1043            Section::Extensions { rows, .. } => {
1044                render_text_types(&mut out, rows, report.size, report.ignored_entries, color);
1045            }
1046            Section::Metrics { view, summary } => {
1047                render_text_metrics(&mut out, *view, summary, report.size, color);
1048            }
1049            // `files` stays one path per line: it is the enumeration, and a bare list is
1050            // what pipes into xargs. The bounded presets are summaries, and a summary
1051            // that ranks by something must show that something — "the twenty largest"
1052            // with no sizes does not answer the question it is named for, and leaves the
1053            // ranking unverifiable.
1054            Section::Files { view, rows, .. } => match view {
1055                ViewSpec::Largest => render_text_ranked_files(&mut out, rows, report.size, |row| {
1056                    human_bytes(pick(report.size, row.bytes, row.allocated))
1057                }),
1058                ViewSpec::Recent => render_text_ranked_files(&mut out, rows, report.size, |row| {
1059                    format_rfc3339_nanos(row.mtime_ns)
1060                }),
1061                _ => {
1062                    for row in rows {
1063                        let _ = writeln!(out, "{}", row.path.display());
1064                    }
1065                }
1066            },
1067            Section::Summary(row) => {
1068                render_text_summary(&mut out, row, report.size, report.ignored_entries);
1069            }
1070        }
1071    }
1072    // Remarks about the report, after the report and before the caller's own epilogue.
1073    // These used to print after the CLI's performance footer, which read as though
1074    // something followed the terminator; and living in the CLI meant only the CLI could
1075    // tell anyone a view had been dropped (fdu-x8u6).
1076    for note in &report.notes {
1077        let _ = writeln!(out, "{}", paint(note, STYLE_TELEMETRY, color));
1078    }
1079    out
1080}
1081
1082// ---- the layout rules -----------------------------------------------------------------
1083//
1084// One contract every grouped row follows, so the four grouped views line up as one table
1085// rather than four:
1086//
1087//   size    right-aligned, width 10
1088//   share   right-aligned, width 6
1089//   label   left-aligned, padded to the section's widest label
1090//   detail  free-form, after a single space
1091//
1092// The rule that is easy to get wrong: **a column's width is measured on visible text**.
1093// `paint` wraps its argument in escape sequences, and a width specifier counts those
1094// toward the field, so `{:<12}` on a painted label is already full before a single visible
1095// character lands and the padding silently collapses. `label_cell` is the only sanctioned
1096// way to lay out a styled label: it measures the plain text and appends the padding
1097// outside the paint. Never hand a painted string to `{:<N}` or `{:>N}`.
1098
1099/// The size column: right-aligned in a fixed width, never styled.
1100const TEXT_SIZE_WIDTH: usize = 10;
1101/// The share column: right-aligned in a fixed width, never styled.
1102const TEXT_SHARE_WIDTH: usize = 6;
1103
1104/// A styled label padded to `width`, measured on the visible text.
1105fn label_cell(label: &str, width: usize, style: AnsiStyle, color: bool) -> String {
1106    let padding = " ".repeat(width.saturating_sub(label.chars().count()));
1107    format!("{}{padding}", paint(label, style, color))
1108}
1109
1110/// The widest visible label in a set of rows, floored at `minimum`.
1111fn label_width<'a>(labels: impl Iterator<Item = &'a str>, minimum: usize) -> usize {
1112    minimum.max(labels.map(|label| label.chars().count()).max().unwrap_or_default())
1113}
1114
1115fn render_text_metrics(
1116    out: &mut String,
1117    view: ViewSpec,
1118    summary: &MetricSummary,
1119    size: SizeMetric,
1120    color: bool,
1121) {
1122    if let Some(note) = share_metric_note(summary.share_metric) {
1123        let _ = writeln!(out, "{}", paint(note, STYLE_TELEMETRY, color));
1124    }
1125    // Languages pad one past the longest name; the other groupings share a floor so
1126    // separate sections still line up with one another.
1127    let width = if view == ViewSpec::Languages {
1128        label_width(summary.rows.iter().map(|row| human_metric_label(view, &row.id)), 0)
1129            .saturating_add(1)
1130    } else {
1131        label_width(
1132            summary.rows.iter().map(|row| human_metric_label(view, &row.id)),
1133            TEXT_METRIC_LABEL_WIDTH,
1134        )
1135    };
1136    for row in &summary.rows {
1137        let selected = pick(size, row.bytes, row.allocated);
1138        let percentage = if row.share.denominator == 0 {
1139            "—".to_string()
1140        } else {
1141            format!("{:.1}%", ratio(row.share.numerator, row.share.denominator) * 100.0)
1142        };
1143        let mut suffix = format!("{} {}", row.files, plural(row.files, "file", "files"));
1144        if let Some(physical_lines) = row.metrics.physical_lines.filter(|lines| *lines > 0) {
1145            let code_fully_analyzed = row.code_coverage.as_ref().is_some_and(|coverage| {
1146                coverage.len() == 1 && coverage.get(&CoverageReason::Analyzed) == Some(&row.files)
1147            });
1148            if let (true, Some(code_lines), Some(comment_lines), Some(code_blank_lines)) = (
1149                code_fully_analyzed,
1150                row.metrics.code_lines,
1151                row.metrics.comment_lines,
1152                row.metrics.code_blank_lines,
1153            ) {
1154                let _ = write!(
1155                    suffix,
1156                    ", {physical_lines} lines ({code_lines} code, {comment_lines} comment, \
1157                     {code_blank_lines} blank)"
1158                );
1159            } else {
1160                let _ = write!(
1161                    suffix,
1162                    ", {} lines ({} nonblank, {} blank)",
1163                    physical_lines,
1164                    row.metrics.nonblank_lines.expect("lines requested"),
1165                    row.metrics.blank_lines.expect("lines requested")
1166                );
1167            }
1168        }
1169        if let Some(page) = pages(row, summary.words_per_page).filter(|page| page.words > 0) {
1170            let page_tenths = page.words.saturating_mul(10) / page.words_per_page;
1171            let _ = write!(
1172                suffix,
1173                ", {} words ({}.{:01} pages)",
1174                page.words,
1175                page_tenths / 10,
1176                page_tenths % 10
1177            );
1178        }
1179        if row.generated_files > 0 {
1180            let _ = write!(suffix, ", {} generated", row.generated_files);
1181        }
1182        if row.vendored_files > 0 {
1183            let _ = write!(suffix, ", {} vendored", row.vendored_files);
1184        }
1185        if row.documentation_files > 0 {
1186            let _ = write!(suffix, ", {} documentation", row.documentation_files);
1187        }
1188        let coverage = match view {
1189            ViewSpec::Languages => row.code_coverage.as_ref().unwrap_or(&row.lines_coverage),
1190            ViewSpec::Documents => row.words_coverage.as_ref().unwrap_or(&row.lines_coverage),
1191            _ => &row.lines_coverage,
1192        };
1193        for (reason, count) in coverage {
1194            if *reason != CoverageReason::Analyzed {
1195                let _ = write!(suffix, ", {count} {}", human_coverage_label(*reason));
1196            }
1197        }
1198        let _ = writeln!(
1199            out,
1200            "{:>TEXT_SIZE_WIDTH$}  {:>TEXT_SHARE_WIDTH$}  {} {suffix}",
1201            human_bytes(selected),
1202            percentage,
1203            label_cell(human_metric_label(view, &row.id), width, STYLE_TYPE, color),
1204        );
1205    }
1206}
1207
1208/// Explain a percentage column whose denominator is not the byte column beside it.
1209///
1210/// Byte shares need no annotation because the adjacent size column already names their
1211/// numerator. Code and document reports deliberately rank by a content metric while
1212/// retaining bytes in the first column, so leaving the percentage unlabeled makes two
1213/// unlike quantities look as though they must agree.
1214fn share_metric_note(metric: ShareMetric) -> Option<&'static str> {
1215    match metric {
1216        ShareMetric::CodeLines => Some("Percentage column: code lines"),
1217        ShareMetric::DocumentWords => Some("Percentage column: document words"),
1218        ShareMetric::RawWords => Some("Percentage column: raw words"),
1219        ShareMetric::ApparentBytes | ShareMetric::AllocatedBytes => None,
1220    }
1221}
1222
1223fn human_metric_label(view: ViewSpec, id: &str) -> &str {
1224    if view == ViewSpec::Languages { human_language_name(id) } else { id }
1225}
1226
1227fn human_coverage_label(reason: CoverageReason) -> &'static str {
1228    match reason {
1229        CoverageReason::Analyzed => "analyzed",
1230        CoverageReason::Binary => "binary",
1231        CoverageReason::InvalidUtf8 => "invalid UTF-8",
1232        CoverageReason::UnsupportedEncoding => "unsupported encoding",
1233        CoverageReason::Unsupported => "unsupported",
1234        CoverageReason::IoError => "I/O error",
1235        CoverageReason::ChangedDuringRead => "changed during read",
1236    }
1237}
1238
1239/// The ignored share a text row ends with, as ` (128 B ignored)`, or nothing.
1240///
1241/// One placement for every row that carries a share: after the row's own detail, so the
1242/// fixed size, bar, and percentage columns keep their alignment. Nothing is appended when
1243/// no file is ignored, when the index observed no control state, or when the selection
1244/// admitted only ignored entries, where the share would repeat the row's size. A share of
1245/// ignored directories alone holds no bytes, and `(0 B ignored)` would say nothing a
1246/// reader can act on; the machine formats still count them. Text cannot tell "nothing
1247/// ignored" from "no rules read"; the performance line says whether any rule was read,
1248/// and machine formats carry a zero share and `null` respectively.
1249fn ignored_suffix(
1250    ignored: Option<IgnoredTally>,
1251    size: SizeMetric,
1252    selected: IgnoredEntries,
1253) -> String {
1254    let shown = match selected {
1255        IgnoredEntries::Include | IgnoredEntries::Exclude => {
1256            ignored.filter(|share| share.files > 0)
1257        }
1258        IgnoredEntries::Only => None,
1259    };
1260    shown.map_or_else(String::new, |share| {
1261        format!(" ({} ignored)", human_bytes(pick(size, share.bytes, share.allocated)))
1262    })
1263}
1264
1265/// Render a tree section with fixed size, bar, and percentage columns.
1266///
1267/// Iterative for the same reason the expansion is: a deep tree must render, not panic.
1268fn render_text_tree(
1269    out: &mut String,
1270    root: &TreeNode,
1271    size: SizeMetric,
1272    selected: IgnoredEntries,
1273    color: bool,
1274) {
1275    enum Row<'a> {
1276        Node(&'a TreeNode, usize),
1277        Truncation(usize),
1278    }
1279
1280    let grand = pick(size, root.bytes, root.allocated);
1281    // Children are pushed in reverse so they pop back in their sorted order. A
1282    // truncation row is pushed first so it appears after the retained children.
1283    let mut stack = vec![Row::Node(root, 0)];
1284    while let Some(row) = stack.pop() {
1285        match row {
1286            Row::Node(node, depth) => {
1287                let bytes = pick(size, node.bytes, node.allocated);
1288                let share = ratio(bytes, grand);
1289                let indent = "  ".repeat(depth);
1290                let _ = writeln!(
1291                    out,
1292                    "{:>10}  {}  {:>4.0}%  {indent}{} ({} {}){}",
1293                    human_bytes(bytes),
1294                    bar(share, color),
1295                    share * 100.0,
1296                    paint(&node.name, STYLE_DIRECTORY, color),
1297                    node.files,
1298                    plural(node.files, "file", "files"),
1299                    ignored_suffix(node.ignored, size, selected),
1300                );
1301                // Reaching the requested depth is visible from the outline itself and
1302                // marking every boundary directory overwhelms a real tree with dots.
1303                // Retained children plus truncation means the sibling list hit its
1304                // limit; that omission gets one marker after the rows that were kept.
1305                if node.truncated && !node.children.is_empty() {
1306                    stack.push(Row::Truncation(depth + 1));
1307                }
1308                for child in node.children.iter().rev() {
1309                    stack.push(Row::Node(child, depth + 1));
1310                }
1311            }
1312            Row::Truncation(depth) => {
1313                let indent = "  ".repeat(depth);
1314                let _ = writeln!(out, "{:>10}  {:10}  {:>5}  {indent}…", "", "", "");
1315            }
1316        }
1317    }
1318}
1319
1320/// Render a types section as aligned rows.
1321fn render_text_types(
1322    out: &mut String,
1323    rows: &[TypeRow],
1324    size: SizeMetric,
1325    selected: IgnoredEntries,
1326    color: bool,
1327) {
1328    let width = label_width(rows.iter().map(|row| row.extension.as_str()), TEXT_TYPE_LABEL_WIDTH);
1329    for row in rows {
1330        let _ = writeln!(
1331            out,
1332            "{:>TEXT_SIZE_WIDTH$}  {} {} {}{}",
1333            human_bytes(pick(size, row.bytes, row.allocated)),
1334            label_cell(&row.extension, width, STYLE_TYPE, color),
1335            row.files,
1336            plural(row.files, "file", "files"),
1337            ignored_suffix(row.ignored, size, selected),
1338        );
1339    }
1340}
1341
1342/// What a section dropped, or nothing when it dropped nothing.
1343///
1344/// Lives in the header rather than after the rows because a footer is lost to `head`,
1345/// which is exactly where a reader most needs telling — `fdu --view largest | head -5`
1346/// would otherwise cut off the only notice that 192,851 rows are missing. In a `full`
1347/// report a header also keeps each bound attached to the section it describes.
1348///
1349/// The flag that lifts the bound is named here too: a truncation the caller cannot remove
1350/// is a limitation wearing a default's clothes.
1351fn bound_note(section: &Section) -> String {
1352    let (shown, total) = match section {
1353        Section::Files { rows, total, .. } => (rows.len(), *total),
1354        Section::Extensions { rows, total } => (rows.len(), *total),
1355        Section::Metrics { summary, .. } => (summary.rows.len(), summary.total_rows),
1356        // A tree marks its dropped children in place, at the depth they were dropped; a
1357        // summary is one row and cannot be bounded.
1358        Section::Tree { .. } | Section::Summary(_) => (0, 0),
1359    };
1360    if shown >= total {
1361        return String::new();
1362    }
1363    format!(
1364        "  ({} of {}; --limit all for every one)",
1365        human_count(shown as u64),
1366        human_count(total as u64)
1367    )
1368}
1369
1370/// A bounded flat listing, showing the measure it was ranked by.
1371///
1372/// The measure comes first at a fixed width so the paths line up under it, matching the
1373/// grouped views' size-then-label shape rather than inventing a third layout.
1374fn render_text_ranked_files(
1375    out: &mut String,
1376    rows: &[FileRow],
1377    _size: SizeMetric,
1378    measure: impl Fn(&FileRow) -> String,
1379) {
1380    let width = rows.iter().map(|row| measure(row).chars().count()).max().unwrap_or_default();
1381    for row in rows {
1382        let _ = writeln!(out, "{:>width$}  {}", measure(row), row.path.display());
1383    }
1384}
1385
1386fn render_text_summary(
1387    out: &mut String,
1388    row: &SummaryRow,
1389    size: SizeMetric,
1390    selected: IgnoredEntries,
1391) {
1392    let _ = writeln!(
1393        out,
1394        "{:>10}  {} {}, {} {}{}",
1395        human_bytes(pick(size, row.bytes, row.allocated)),
1396        row.files,
1397        plural(row.files, "file", "files"),
1398        row.dirs,
1399        plural(row.dirs, "directory", "directories"),
1400        ignored_suffix(row.ignored, size, selected),
1401    );
1402}
1403
1404/// Quote a YAML scalar whenever a bare word would be ambiguous.
1405///
1406/// Always quoting would be simpler and uglier; quoting only what needs it keeps the
1407/// output readable, which is the reason to offer YAML at all.
1408#[cfg(test)]
1409fn yaml_scalar(value: &str) -> String {
1410    let mut out = String::new();
1411    crate::emit::write_yaml_scalar(&mut out, value);
1412    out
1413}
1414
1415/// Quote and escape a string as a JSON scalar.
1416#[cfg(test)]
1417fn quote(text: &str) -> String {
1418    let mut out = String::with_capacity(text.len() + 2);
1419    crate::emit::write_json_string(&mut out, text);
1420    out
1421}
1422
1423/// This binary's identity, for the `generator` field.
1424fn generator() -> String {
1425    format!("fdu {}", env!("CARGO_PKG_VERSION"))
1426}
1427
1428/// All-caps header naming a view in multi-view text output.
1429///
1430/// Deliberately not `view.label().to_uppercase()`: the wire label is a schema promise
1431/// machine consumers match on, and deriving the human header from it would let a
1432/// presentation change reach into the schema, or freeze the schema for a presentation
1433/// reason. They spell the same word today because the same word is right in both places,
1434/// and a test holds them in step rather than a shared expression.
1435fn view_header(view: ViewSpec) -> &'static str {
1436    match view {
1437        ViewSpec::List => "LIST",
1438        ViewSpec::Tree => "TREE",
1439        ViewSpec::Types => "TYPES",
1440        ViewSpec::Extensions => "EXTENSIONS",
1441        ViewSpec::Families => "FAMILIES",
1442        ViewSpec::Languages => "LANGUAGES",
1443        ViewSpec::Documents => "DOCUMENTS",
1444        ViewSpec::Files => "FILES",
1445        ViewSpec::Largest => "LARGEST",
1446        ViewSpec::Recent => "RECENT",
1447        ViewSpec::Summary => "SUMMARY",
1448    }
1449}
1450
1451fn metric_group_label(group: MetricGroup) -> &'static str {
1452    match group {
1453        MetricGroup::Type => "type",
1454        MetricGroup::Family => "family",
1455    }
1456}
1457
1458fn coverage_label(reason: CoverageReason) -> &'static str {
1459    match reason {
1460        CoverageReason::Analyzed => "analyzed",
1461        CoverageReason::Binary => "binary",
1462        CoverageReason::InvalidUtf8 => "invalid_utf8",
1463        CoverageReason::UnsupportedEncoding => "unsupported_encoding",
1464        CoverageReason::Unsupported => "unsupported",
1465        CoverageReason::IoError => "io_error",
1466        CoverageReason::ChangedDuringRead => "changed_during_read",
1467    }
1468}
1469
1470/// The requested analyzer set, in the vocabulary `--analyze` accepts.
1471///
1472/// A list rather than one label because the set is what was requested; the neighbouring
1473/// `analyzers` array reports what actually ran, with each dialect's version.
1474fn analysis_set_labels(profile: crate::content::AnalysisSet) -> Vec<&'static str> {
1475    profile.labels()
1476}
1477
1478/// Stable wire label for a cache tier.
1479fn source_label(source: ReportSource) -> &'static str {
1480    match source {
1481        ReportSource::ColdScan => "cold_scan",
1482        ReportSource::WarmRevalidate => "warm_revalidate",
1483        ReportSource::CacheOnly => "cache_only",
1484    }
1485}
1486
1487fn tier_source_label(source: Source) -> &'static str {
1488    match source {
1489        Source::Scanned => "scanned",
1490        Source::Revalidated => "revalidated",
1491        Source::JournalScoped => "journal_scoped",
1492        Source::Cached => "cached",
1493    }
1494}
1495
1496fn structural_coverage_label(reason: crate::engine_contract::CoverageReason) -> &'static str {
1497    match reason {
1498        crate::engine_contract::CoverageReason::Building => "building",
1499        crate::engine_contract::CoverageReason::Budget => "budget",
1500        crate::engine_contract::CoverageReason::Cancelled => "cancelled",
1501        crate::engine_contract::CoverageReason::Inaccessible => "inaccessible",
1502        crate::engine_contract::CoverageReason::Failed => "failed",
1503    }
1504}
1505
1506fn issue_kind_label(kind: IssueKind) -> &'static str {
1507    match kind {
1508        IssueKind::Permission => "permission",
1509        IssueKind::Disappeared => "disappeared",
1510        IssueKind::InvalidMetadata => "invalid_metadata",
1511        IssueKind::ResourceBudget => "resource_budget",
1512        IssueKind::ObservationGap => "observation_gap",
1513        IssueKind::ProviderFailure => "provider_failure",
1514    }
1515}
1516
1517/// Stable wire label for freshness.
1518fn freshness_label(freshness: Freshness) -> &'static str {
1519    match freshness {
1520        Freshness::Fresh => "fresh",
1521        Freshness::Reconciling => "reconciling",
1522        Freshness::Stale => "stale",
1523        Freshness::Partial => "partial",
1524    }
1525}
1526
1527/// Stable wire label for an entry kind.
1528fn kind_label(kind: EntryKind) -> &'static str {
1529    match kind {
1530        EntryKind::File => "file",
1531        EntryKind::Dir => "dir",
1532        EntryKind::Symlink => "symlink",
1533        EntryKind::Other => "other",
1534    }
1535}
1536
1537/// The byte count for the metric a report answers in.
1538fn pick(size: SizeMetric, apparent: u64, allocated: u64) -> u64 {
1539    match size {
1540        SizeMetric::Apparent => apparent,
1541        SizeMetric::Allocated => allocated,
1542    }
1543}
1544
1545/// Pick the singular or plural noun for a count.
1546fn plural<'a>(count: u64, singular: &'a str, plural: &'a str) -> &'a str {
1547    if count == 1 { singular } else { plural }
1548}
1549
1550/// A bounded share for the human size bar and percentage.
1551fn ratio(part: u64, whole: u64) -> f64 {
1552    if whole == 0 {
1553        return 0.0;
1554    }
1555    #[allow(clippy::cast_precision_loss)]
1556    let share = part as f64 / whole as f64;
1557    share.clamp(0.0, 1.0)
1558}
1559
1560// Rounding a fraction to one of eleven bar widths is exactly the case where float-cast
1561// lints have nothing to protect: the value is clamped to [0, 1] before the cast and the
1562// result is clamped to WIDTH after it.
1563#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss)]
1564fn bar(share: f64, color: bool) -> String {
1565    const WIDTH: usize = 10;
1566    let filled = ((share.clamp(0.0, 1.0) * WIDTH as f64).round() as usize).min(WIDTH);
1567    let rendered = format!("{}{}", "█".repeat(filled), "░".repeat(WIDTH - filled));
1568    paint(&rendered, STYLE_BAR, color)
1569}
1570
1571/// Render a count with thousands separators, the way every fdu report does.
1572///
1573/// Lived in the command line, and `report_format` called *into* it -- so the library
1574/// depended on its own front end, which is the inverse of the rule that the CLI invents
1575/// nothing. A crate boundary rejects that outright, which is how it was found.
1576pub fn human_count(value: u64) -> String {
1577    let digits = value.to_string();
1578    let mut grouped = String::with_capacity(digits.len() + digits.len() / 3);
1579    for (index, byte) in digits.bytes().enumerate() {
1580        if index > 0 && (digits.len() - index) % 3 == 0 {
1581            grouped.push(',');
1582        }
1583        grouped.push(char::from(byte));
1584    }
1585    grouped
1586}
1587
1588/// Render a byte count at human scale, the way every fdu report does.
1589///
1590/// Public because a caller formatting fdu's numbers should not reimplement its unit
1591/// rules: two spellings of one quantity inside a single tool is how a report and the
1592/// line summarising it come to disagree.
1593pub fn human_bytes(bytes: u64) -> String {
1594    const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
1595    // Integer arithmetic to the unit, then one bounded division for the tenths digit:
1596    // a byte count can exceed f64's exact-integer range, and a size that renders wrong
1597    // at the top of the scale is worse than one that renders plainly.
1598    let mut whole = bytes;
1599    let mut remainder = 0u64;
1600    let mut unit = 0;
1601    while whole >= 1024 && unit + 1 < UNITS.len() {
1602        remainder = whole % 1024;
1603        whole /= 1024;
1604        unit += 1;
1605    }
1606    if unit == 0 {
1607        format!("{bytes} B")
1608    } else if whole < 10 {
1609        let tenths = (remainder * 10) / 1024;
1610        format!("{whole}.{tenths} {}", UNITS[unit])
1611    } else {
1612        format!("{whole} {}", UNITS[unit])
1613    }
1614}
1615
1616/// Whether a path renders losslessly as UTF-8.
1617///
1618/// Non-UTF-8 names exist and a report must not pretend otherwise; the CLI layer adds the
1619/// raw-bytes companion field, and this is the predicate that decides when.
1620pub fn is_lossy(path: &Path) -> bool {
1621    path.to_str().is_none()
1622}
1623
1624/// Streaming-output schema identity.
1625///
1626/// Distinct from the one-shot schema on purpose: a stream is a sequence of tagged
1627/// records over time, not one document, and a consumer should not have to discover which
1628/// it is holding.
1629pub const STREAM_SCHEMA: &str = "fdu.stream/2";
1630
1631/// The rule drawn above a watch repaint, carrying the instant it was rendered.
1632///
1633/// The time is what makes the rule worth a line rather than a bare separator: a watch
1634/// reader wants to know when the tree last moved, and it is the one fact that
1635/// distinguishes one repaint from another whose numbers happen to match. RFC 3339 in UTC
1636/// is the spelling every other timestamp this tool prints uses.
1637///
1638/// Lives here rather than in the command line because it is presentation, and a caller
1639/// repainting fdu's views should draw fdu's separator rather than invent one that will
1640/// drift from it.
1641pub fn watch_rule(at: std::time::SystemTime) -> String {
1642    format!("──── {} ────", format_rfc3339(at))
1643}
1644
1645/// The watch repaint rule for an integer nanosecond timestamp.
1646///
1647/// This is distinct from [`watch_rule`] because an integer can carry finer precision than
1648/// the platform's [`std::time::SystemTime`]. In particular, Windows would otherwise
1649/// truncate the final two digits while converting through 100-nanosecond FILETIME ticks.
1650pub fn watch_rule_nanos(at_nanos: i64) -> String {
1651    format!("──── {} ────", format_rfc3339_nanos(at_nanos))
1652}
1653
1654/// Render one streamed change as a tagged record.
1655#[cfg(feature = "watch")]
1656pub fn render_change(change: &crate::Change, format: Format) -> String {
1657    let kind = match change.kind {
1658        crate::ChangeKind::Upsert => "upsert",
1659        crate::ChangeKind::Remove => "remove",
1660        crate::ChangeKind::Invalidate => "invalidate",
1661    };
1662
1663    if !format.is_machine() {
1664        // Path first, so the stream stays greppable and cuts the same way a one-shot
1665        // listing does; the operation follows on the same line.
1666        return format!("{}\t{kind}", change.path.display());
1667    }
1668
1669    match format {
1670        Format::Json | Format::Jsonl => render_change_machine(change, kind, JsonSink::line()),
1671        Format::Yaml => {
1672            format!(
1673                "{}{}",
1674                document_start(format),
1675                render_change_machine(change, kind, YamlSink::new())
1676            )
1677        }
1678        Format::Text | Format::Tree | Format::Paths | Format::Long => {
1679            unreachable!("text returned above")
1680        }
1681    }
1682}
1683
1684#[cfg(feature = "watch")]
1685fn render_change_machine(
1686    change: &crate::Change,
1687    kind: &str,
1688    mut sink: impl Sink<Output = String>,
1689) -> String {
1690    sink.event(Event::BeginMap(Shape::Block));
1691    emit_str_field(&mut sink, "schema", STREAM_SCHEMA);
1692    emit_str_field(&mut sink, "record", "change");
1693    emit_str_field(&mut sink, "op", kind);
1694    emit_path_fields(&mut sink, &change.path);
1695    emit_u64_field(&mut sink, "clock", change.clock);
1696    emit_field(&mut sink, Field::when_set("kind"), change.entry_kind.is_some(), |sink| {
1697        emit_scalar(sink, Scalar::Str(kind_label(change.entry_kind.expect("presence checked"))));
1698    });
1699    emit_field(&mut sink, Field::when_set("bytes"), change.bytes.is_some(), |sink| {
1700        emit_scalar(sink, Scalar::U64(change.bytes.expect("presence checked")));
1701    });
1702    emit_field(&mut sink, Field::when_set("allocated"), change.allocated.is_some(), |sink| {
1703        emit_scalar(sink, Scalar::U64(change.allocated.expect("presence checked")));
1704    });
1705    emit_field(&mut sink, Field::when_set("mtime_ns"), change.mtime_ns.is_some(), |sink| {
1706        emit_scalar(sink, Scalar::I64(change.mtime_ns.expect("presence checked")));
1707    });
1708    emit_field(&mut sink, Field::when_set("ignored"), change.ignored.is_some(), |sink| {
1709        emit_scalar(sink, Scalar::Bool(change.ignored.expect("presence checked")));
1710    });
1711    sink.event(Event::EndMap);
1712    sink.finish()
1713}
1714
1715/// Lossless identity for a path that does not render as UTF-8.
1716///
1717/// `to_string_lossy` replaces undecodable bytes with U+FFFD, so a consumer reading only
1718/// `root` cannot tell two different names apart. Machine output therefore carries the
1719/// native bytes alongside, and only when they are actually needed.
1720fn raw_os_identity(value: &std::ffi::OsStr) -> Option<(&'static str, String)> {
1721    if value.to_str().is_some() {
1722        return None;
1723    }
1724
1725    #[cfg(unix)]
1726    {
1727        use std::os::unix::ffi::OsStrExt;
1728
1729        Some(("unix-bytes", hex_bytes(value.as_bytes().iter().copied())))
1730    }
1731
1732    #[cfg(windows)]
1733    {
1734        use std::os::windows::ffi::OsStrExt;
1735
1736        Some(("windows-wtf16le", hex_bytes(value.encode_wide().flat_map(u16::to_le_bytes))))
1737    }
1738
1739    #[cfg(not(any(unix, windows)))]
1740    {
1741        None
1742    }
1743}
1744
1745/// Hex-encode bytes for the raw identity field.
1746#[cfg(any(unix, windows))]
1747fn hex_bytes(bytes: impl IntoIterator<Item = u8>) -> String {
1748    let mut out = String::new();
1749    for byte in bytes {
1750        let _ = write!(out, "{byte:02x}");
1751    }
1752    out
1753}
1754
1755/// Render cache status in any format.
1756///
1757/// A separate entry point rather than a `Report` section: cache status is a fact about
1758/// the cache directory, not about a tree, and folding it into the report schema would
1759/// make every consumer parse a variant that is empty on every normal run.
1760///
1761/// `scope` is the request the statuses answer. It decides only which command the text
1762/// names for reclaiming stale snapshots: a root's snapshot is cleared by its path, while a
1763/// stale file found by listing the directory may name no root this build can read.
1764///
1765/// Every machine format carries [`CACHE_SCHEMA`], the way every machine report carries its
1766/// own: the first field of the JSON document, an envelope line of its own ahead of the
1767/// rows in JSON Lines, and the first line of the YAML.
1768pub fn render_cache_status(
1769    statuses: &[crate::CacheStatus],
1770    scope: crate::CacheScope,
1771    format: Format,
1772) -> String {
1773    match format {
1774        Format::Jsonl => {
1775            let mut sink = JsonSink::line();
1776            sink.event(Event::BeginMap(Shape::Inline));
1777            emit_str_field(&mut sink, "schema", CACHE_SCHEMA);
1778            sink.event(Event::EndMap);
1779            let mut out = sink.finish();
1780            for status in statuses {
1781                out.push('\n');
1782                let row = cache_row(status);
1783                let mut sink = JsonSink::line();
1784                emit_cache_field(&mut sink, &row);
1785                out.push_str(&sink.finish());
1786            }
1787            out
1788        }
1789        Format::Yaml => render_cache_machine(statuses, YamlSink::new()),
1790        // The human layout lives here beside every other human layout. It used to live in
1791        // the CLI, which meant the only way to print cache status the way fdu prints it
1792        // was to be the CLI: the Python API returned CacheStatus values nothing could
1793        // render, so the parity shim printed repr() and nine sessions differed (fdu-1kw3).
1794        Format::Text | Format::Tree | Format::Paths | Format::Long => {
1795            render_cache_status_text(statuses, scope)
1796        }
1797        Format::Json => render_cache_machine(statuses, JsonSink::pretty()),
1798    }
1799}
1800
1801fn render_cache_machine(
1802    statuses: &[crate::CacheStatus],
1803    mut sink: impl Sink<Output = String>,
1804) -> String {
1805    sink.event(Event::BeginMap(Shape::Block));
1806    emit_str_field(&mut sink, "schema", CACHE_SCHEMA);
1807    emit_field(&mut sink, Field::always("caches"), true, |sink| {
1808        sink.event(Event::BeginSeq(Shape::Block));
1809        for status in statuses {
1810            let row = cache_row(status);
1811            emit_cache_field(sink, &row);
1812        }
1813        sink.event(Event::EndSeq);
1814    });
1815    sink.event(Event::EndMap);
1816    sink.finish()
1817}
1818
1819fn emit_cache_field(sink: &mut impl Sink, field: &CacheField) {
1820    match field {
1821        CacheField::Null => emit_scalar(sink, Scalar::Null),
1822        CacheField::Bool(value) => emit_scalar(sink, Scalar::Bool(*value)),
1823        CacheField::Count(value) => emit_scalar(sink, Scalar::U64(*value)),
1824        CacheField::Text(value) => emit_scalar(sink, Scalar::Str(value)),
1825        CacheField::List(values) => {
1826            sink.event(Event::BeginSeq(Shape::Block));
1827            for value in values {
1828                emit_cache_field(sink, value);
1829            }
1830            sink.event(Event::EndSeq);
1831        }
1832        CacheField::Map(fields) => {
1833            sink.event(Event::BeginMap(Shape::Block));
1834            for (name, value) in fields {
1835                sink.event(Event::Key(name));
1836                emit_cache_field(sink, value);
1837            }
1838            sink.event(Event::EndMap);
1839        }
1840    }
1841}
1842
1843/// One value in a cache-status row.
1844///
1845/// The row is built once as fields and serialized by JSON and YAML alike, so the two
1846/// formats cannot disagree about which keys a row has or how an identity nests.
1847enum CacheField {
1848    Null,
1849    Bool(bool),
1850    Count(u64),
1851    Text(String),
1852    List(Vec<CacheField>),
1853    Map(Vec<(&'static str, CacheField)>),
1854}
1855
1856impl CacheField {
1857    fn count(value: Option<u64>) -> Self {
1858        value.map_or(Self::Null, Self::Count)
1859    }
1860}
1861
1862/// One cache-status row as fields: what every row carries, what its state adds, and the
1863/// content sidecar beside it.
1864fn cache_row(status: &crate::CacheStatus) -> CacheField {
1865    use crate::CacheState;
1866
1867    let mut fields = vec![("path", CacheField::Text(status.path.to_string_lossy().into_owned()))];
1868    if let Some((encoding, hex)) = raw_os_identity(status.path.as_os_str()) {
1869        fields.push((
1870            "path_raw",
1871            CacheField::Map(vec![
1872                ("encoding", CacheField::Text(encoding.to_string())),
1873                ("hex", CacheField::Text(hex)),
1874            ]),
1875        ));
1876    }
1877    fields.extend([
1878        ("bytes", CacheField::Count(status.bytes)),
1879        ("state", CacheField::Text(status.state.label().to_string())),
1880    ]);
1881    match &status.state {
1882        CacheState::Current(info) => {
1883            fields.push(("root", CacheField::Text(info.root.to_string_lossy().into_owned())));
1884            if let Some((encoding, hex)) = raw_os_identity(info.root.as_os_str()) {
1885                fields.push((
1886                    "root_raw",
1887                    CacheField::Map(vec![
1888                        ("encoding", CacheField::Text(encoding.to_string())),
1889                        ("hex", CacheField::Text(hex)),
1890                    ]),
1891                ));
1892            }
1893            fields.push(("entries", CacheField::Count(info.entries)));
1894            fields.push(("identity", snapshot_identity_field(info.identity)));
1895        }
1896        CacheState::Stale(reason) => fields.extend(stale_fields(*reason)),
1897        CacheState::Leftover(kind) => {
1898            fields.push(("leftover_kind", CacheField::Text(kind.label().to_string())));
1899        }
1900        CacheState::Unrecognized | CacheState::Absent => {}
1901    }
1902    // Every row carries it, whatever the state, so a consumer reads one shape rather than
1903    // discovering which keys this row happens to have.
1904    fields.push(("content", status.content.as_ref().map_or(CacheField::Null, content_field)));
1905    CacheField::Map(fields)
1906}
1907
1908/// Why a store is stale, and the format version when that is the reason.
1909fn stale_fields(reason: crate::StaleReason) -> [(&'static str, CacheField); 2] {
1910    [
1911        ("stale_reason", CacheField::Text(reason.label().to_string())),
1912        ("format_version", CacheField::count(reason.format_version().map(u64::from))),
1913    ]
1914}
1915
1916/// The content sidecar beside a snapshot: its size and state, and a current one's identity
1917/// and record count.
1918fn content_field(content: &crate::ContentStatus) -> CacheField {
1919    use crate::ContentState;
1920
1921    let mut fields = vec![
1922        ("bytes", CacheField::Count(content.bytes)),
1923        ("state", CacheField::Text(content.state.label().to_string())),
1924    ];
1925    match &content.state {
1926        ContentState::Current(info) => {
1927            fields.push(("records", CacheField::Count(info.records)));
1928            fields.push(("identity", content_identity_field(&info.identity)));
1929        }
1930        ContentState::Stale(reason) => fields.extend(stale_fields(*reason)),
1931    }
1932    CacheField::Map(fields)
1933}
1934
1935/// A snapshot's tier identities: its entry tier, and its `.gitignore` control tier as the
1936/// report's `ignore_rules` names it, `null` when no rule was read.
1937fn snapshot_identity_field(identity: crate::SnapshotIdentity) -> CacheField {
1938    let ignore_rules = match identity.controls {
1939        crate::ControlTierIdentity::NotObserved => CacheField::Null,
1940        crate::ControlTierIdentity::Observed { limits } => {
1941            let limit = |limit: Option<usize>| {
1942                CacheField::count(limit.map(|limit| u64::try_from(limit).unwrap_or(u64::MAX)))
1943            };
1944            CacheField::Map(vec![(
1945                "limits",
1946                CacheField::Map(vec![
1947                    ("budget", limit(limits.budget)),
1948                    ("line_limit", limit(limits.line_limit)),
1949                ]),
1950            )])
1951        }
1952    };
1953    CacheField::Map(vec![
1954        ("entries", entry_identity_field(identity.entries)),
1955        ("ignore_rules", ignore_rules),
1956    ])
1957}
1958
1959/// An entry tier's identity: the engine that built it, the scope fields, and the type-rules
1960/// and reducer-set fingerprints.
1961fn entry_identity_field(identity: crate::EntryTierIdentity) -> CacheField {
1962    let scope = identity.scope;
1963    let depth = scope.max_depth.map(|depth| u64::try_from(depth).unwrap_or(u64::MAX));
1964    CacheField::Map(vec![
1965        ("engine", CacheField::Count(identity.engine)),
1966        ("max_depth", CacheField::count(depth)),
1967        ("follow_symlinks", CacheField::Bool(scope.follow_symlinks)),
1968        ("one_filesystem", CacheField::Bool(scope.one_filesystem)),
1969        ("hidden_fingerprint", CacheField::Count(scope.hidden_fingerprint)),
1970        ("exclude_special", CacheField::Bool(scope.exclude_special)),
1971        ("type_rules_fingerprint", CacheField::Count(identity.type_rules_fingerprint)),
1972        ("reducers_fingerprint", CacheField::Count(identity.reducers_fingerprint)),
1973    ])
1974}
1975
1976/// A content tier's identity: the entry tier it was analyzed over, which holds its type
1977/// rules, then the analyzer set, options, and analyzers under the names a report's
1978/// `analysis` object gives them.
1979fn content_identity_field(identity: &crate::ContentTierIdentity) -> CacheField {
1980    let analyze = analysis_set_labels(identity.analysis)
1981        .into_iter()
1982        .map(|label| CacheField::Text(label.to_string()))
1983        .collect();
1984    let analyzers = identity
1985        .provenance
1986        .analyzers
1987        .iter()
1988        .map(|(id, version)| {
1989            CacheField::Map(vec![
1990                ("id", CacheField::Text(id.0.to_string())),
1991                ("version", CacheField::Count(u64::from(version.0))),
1992            ])
1993        })
1994        .collect();
1995    CacheField::Map(vec![
1996        ("entries", entry_identity_field(identity.entries)),
1997        ("analyze", CacheField::List(analyze)),
1998        ("options_fingerprint", CacheField::Count(identity.provenance.options_fingerprint.0)),
1999        ("analyzers", CacheField::List(analyzers)),
2000    ])
2001}
2002
2003/// The human cache-status layout: one line per file, then what can be done about the
2004/// files this build cannot use.
2005fn render_cache_status_text(statuses: &[crate::CacheStatus], scope: crate::CacheScope) -> String {
2006    use crate::{CacheScope, CacheState, LeftoverKind, StaleReason};
2007
2008    let mut lines = Vec::new();
2009    let mut current = 0_usize;
2010    let (mut stale, mut stale_bytes) = (0_usize, 0_u64);
2011    let (mut leftover, mut leftover_bytes, mut staging) = (0_usize, 0_u64, 0_usize);
2012    let (mut unrecognized, mut unrecognized_bytes) = (0_usize, 0_u64);
2013    for status in statuses {
2014        let content_bytes = status.content_bytes().unwrap_or(0);
2015        match &status.state {
2016            CacheState::Current(info) => {
2017                current += 1;
2018                // A sidecar this build cannot serve is named, so the bytes are not read as a
2019                // usable content cache.
2020                let stale_content = status
2021                    .content
2022                    .as_ref()
2023                    .is_some_and(|content| matches!(content.state, crate::ContentState::Stale(_)));
2024                lines.push(format!(
2025                    "{}  {} entries, {} metadata bytes, {content_bytes} {}content bytes  {}",
2026                    status.path.display(),
2027                    info.entries,
2028                    status.bytes,
2029                    if stale_content { "stale " } else { "" },
2030                    info.root.display()
2031                ));
2032            }
2033            CacheState::Stale(reason) => {
2034                stale += 1;
2035                stale_bytes =
2036                    stale_bytes.saturating_add(status.bytes).saturating_add(content_bytes);
2037                let why = match reason {
2038                    StaleReason::OlderFormat { version } => {
2039                        format!("older snapshot format {version}")
2040                    }
2041                    StaleReason::NewerFormat { version } => {
2042                        format!("newer snapshot format {version}")
2043                    }
2044                    StaleReason::OtherEngine => "written by another fdu version".to_string(),
2045                    StaleReason::Unreadable => "unreadable by this build".to_string(),
2046                };
2047                lines.push(format!(
2048                    "{}  stale ({why}), {} metadata bytes, {content_bytes} content bytes",
2049                    status.path.display(),
2050                    status.bytes
2051                ));
2052            }
2053            CacheState::Leftover(kind) => {
2054                leftover += 1;
2055                leftover_bytes = leftover_bytes.saturating_add(status.bytes);
2056                let what = match kind {
2057                    LeftoverKind::StagingTemporary => {
2058                        staging += 1;
2059                        "staging temporary"
2060                    }
2061                    LeftoverKind::OrphanedContent => "orphaned content sidecar",
2062                };
2063                lines.push(format!(
2064                    "{}  leftover ({what}), {} bytes",
2065                    status.path.display(),
2066                    status.bytes
2067                ));
2068            }
2069            CacheState::Unrecognized => {
2070                unrecognized += 1;
2071                unrecognized_bytes = unrecognized_bytes.saturating_add(status.bytes);
2072                lines.push(format!(
2073                    "{}  unrecognized, {} bytes",
2074                    status.path.display(),
2075                    status.bytes
2076                ));
2077            }
2078            // Root scope synthesises a status for the path a snapshot *would* occupy, so a
2079            // tree that has never been cached yields one absent entry. Absence is not a
2080            // file to describe.
2081            CacheState::Absent => {}
2082        }
2083    }
2084    if lines.is_empty() {
2085        return "No cached snapshots.".to_string();
2086    }
2087
2088    if stale > 0 {
2089        let (subject, object) = if stale == 1 {
2090            ("1 stale snapshot".to_string(), "it")
2091        } else {
2092            (format!("{stale} stale snapshots"), "them")
2093        };
2094        let remedy = match scope {
2095            CacheScope::Root => format!("fdu --cache-clear PATH removes {object}"),
2096            CacheScope::All if current == 0 => format!("fdu --cache-clear=all removes {object}"),
2097            CacheScope::All => {
2098                format!("fdu --cache-clear=all removes {object}, along with every current snapshot")
2099            }
2100        };
2101        lines.push(format!(
2102            "{subject} ({stale_bytes} bytes) cannot be served by this build; {remedy}."
2103        ));
2104    }
2105    if leftover > 0 {
2106        // Named as fdu's own, because they are: calling them foreign would tell the user
2107        // to leave fdu's debris alone. `=all` is the scope that reclaims them; a root's
2108        // clear reaches only the one path that root's snapshot occupies.
2109        let (subject, predicate, object) = if leftover == 1 {
2110            ("1 leftover file".to_string(), "is", "it")
2111        } else {
2112            (format!("{leftover} leftover files"), "are", "them")
2113        };
2114        // A staging file is reclaimed only once it is too old to belong to a running
2115        // writer, and a status knows no file's age, so the promise names the exception
2116        // rather than counting files the clear will then decline and explain.
2117        let caveat = if staging > 0 {
2118            ", though a staging file waits until it is too old to be a running writer's"
2119        } else {
2120            ""
2121        };
2122        lines.push(format!(
2123            "{subject} ({leftover_bytes} bytes) {predicate} fdu's own, left by an interrupted \
2124             write; fdu --cache-clear=all reclaims {object}{caveat}."
2125        ));
2126    }
2127    if unrecognized > 0 {
2128        let (subject, predicate, object) = if unrecognized == 1 {
2129            ("1 unrecognized file".to_string(), "is not an fdu snapshot", "it")
2130        } else {
2131            (format!("{unrecognized} unrecognized files"), "are not fdu snapshots", "them")
2132        };
2133        lines.push(format!(
2134            "{subject} ({unrecognized_bytes} bytes) {predicate}, so fdu leaves {object} in place."
2135        ));
2136    }
2137    lines.join("\n")
2138}
2139
2140#[cfg(test)]
2141mod tests {
2142    fn render(report: &Report, format: Format, color: bool) -> String {
2143        super::render(report, format, color).expect("compatible report format")
2144    }
2145
2146    use super::*;
2147    use crate::Index;
2148    use crate::engine_contract::{Attrs, Observation, Op, ScanScope};
2149    use crate::query::{Bound, Query, Request, Selection};
2150    use std::ffi::OsStr;
2151    use std::path::PathBuf;
2152    use std::process::Command;
2153    use std::time::{Duration, SystemTime, UNIX_EPOCH};
2154
2155    struct Provenance {
2156        scan_started_at: Option<SystemTime>,
2157        generated_at: SystemTime,
2158        source: ReportSource,
2159        complete: bool,
2160        errors: Vec<String>,
2161    }
2162
2163    fn report(index: &Index, request: &Request, provenance: &Provenance) -> crate::Result<Report> {
2164        let mut report = crate::query::report(index, request, provenance.generated_at)?;
2165        report.provenance.scan_started_at = provenance.scan_started_at;
2166        report.provenance.source = provenance.source;
2167        report.status.complete = provenance.complete;
2168        report.status.errors = provenance
2169            .errors
2170            .iter()
2171            .cloned()
2172            .map(|message| crate::Issue::provider_failure(None, message))
2173            .collect();
2174        Ok(report)
2175    }
2176
2177    fn attrs(size: u64, mtime_ns: i64) -> Attrs {
2178        Attrs {
2179            size,
2180            allocated: size.div_ceil(512) * 512,
2181            mtime_ns,
2182            ctime_ns: mtime_ns,
2183            inode: 7,
2184            dev: 1,
2185        }
2186    }
2187
2188    fn cache_file(name: &str, bytes: u64, state: crate::CacheState) -> crate::CacheStatus {
2189        // A stale snapshot here keeps the sidecar an older format wrote beside it.
2190        let content =
2191            matches!(state, crate::CacheState::Stale(_)).then_some(crate::ContentStatus {
2192                bytes: 5,
2193                state: crate::ContentState::Stale(crate::StaleReason::OlderFormat { version: 4 }),
2194            });
2195        crate::CacheStatus { path: PathBuf::from(name), bytes, content, state }
2196    }
2197
2198    /// A snapshot identity with a small value in every field, so a rendering is readable.
2199    fn small_snapshot_identity() -> crate::SnapshotIdentity {
2200        crate::SnapshotIdentity {
2201            entries: crate::EntryTierIdentity {
2202                engine: 1,
2203                scope: crate::EntryScope {
2204                    max_depth: None,
2205                    follow_symlinks: false,
2206                    one_filesystem: true,
2207                    hidden_fingerprint: 2,
2208                    exclude_special: false,
2209                },
2210                type_rules_fingerprint: 3,
2211                reducers_fingerprint: 4,
2212            },
2213            controls: crate::ControlTierIdentity::Observed {
2214                limits: crate::control::ControlLimits { budget: Some(10), line_limit: None },
2215            },
2216        }
2217    }
2218
2219    /// Stale, leftover, and unrecognized files are shown, sized, and followed by what
2220    /// reclaims them.
2221    ///
2222    /// A unit test beside the goldens because a golden cannot produce a newer format or
2223    /// every reason at once, and because the remedy depends on the scope and on whether
2224    /// clearing would also take current snapshots.
2225    #[test]
2226    fn cache_status_shows_stale_and_unrecognized_files_with_their_remedy() {
2227        use crate::{CacheScope, CacheState, LeftoverKind, StaleReason};
2228
2229        let stale = [
2230            cache_file("a.fdu", 10, CacheState::Stale(StaleReason::OlderFormat { version: 2 })),
2231            cache_file("b.fdu", 20, CacheState::Stale(StaleReason::NewerFormat { version: 99 })),
2232            cache_file("c.fdu", 30, CacheState::Stale(StaleReason::OtherEngine)),
2233            cache_file("d.fdu", 40, CacheState::Stale(StaleReason::Unreadable)),
2234            cache_file("notes.txt", 14, CacheState::Unrecognized),
2235        ];
2236        assert_eq!(
2237            render_cache_status(&stale, CacheScope::All, Format::Text),
2238            "a.fdu  stale (older snapshot format 2), 10 metadata bytes, 5 content bytes\n\
2239             b.fdu  stale (newer snapshot format 99), 20 metadata bytes, 5 content bytes\n\
2240             c.fdu  stale (written by another fdu version), 30 metadata bytes, 5 content bytes\n\
2241             d.fdu  stale (unreadable by this build), 40 metadata bytes, 5 content bytes\n\
2242             notes.txt  unrecognized, 14 bytes\n\
2243             4 stale snapshots (120 bytes) cannot be served by this build; \
2244             fdu --cache-clear=all removes them.\n\
2245             1 unrecognized file (14 bytes) is not an fdu snapshot, so fdu leaves it in place."
2246        );
2247
2248        // fdu's own debris is named as fdu's, so a reader is not told to leave it alone.
2249        let leftovers = [
2250            cache_file(
2251                ".g.fdu.tmp.1.2.3",
2252                60,
2253                CacheState::Leftover(LeftoverKind::StagingTemporary),
2254            ),
2255            cache_file("h.fdu.content", 70, CacheState::Leftover(LeftoverKind::OrphanedContent)),
2256        ];
2257        assert_eq!(
2258            render_cache_status(&leftovers, CacheScope::All, Format::Text),
2259            ".g.fdu.tmp.1.2.3  leftover (staging temporary), 60 bytes\n\
2260             h.fdu.content  leftover (orphaned content sidecar), 70 bytes\n\
2261             2 leftover files (130 bytes) are fdu's own, left by an interrupted write; \
2262             fdu --cache-clear=all reclaims them, though a staging file waits until it is \
2263             too old to be a running writer's."
2264        );
2265        assert!(render_cache_status(&leftovers[..1], CacheScope::Root, Format::Text).ends_with(
2266            "1 leftover file (60 bytes) is fdu's own, left by an interrupted write; \
2267                 fdu --cache-clear=all reclaims it, though a staging file waits until it \
2268                 is too old to be a running writer's."
2269        ));
2270        // With no staging file listed, nothing is held back and the promise is plain: a
2271        // status that named an exception with no file it could apply to would be noise.
2272        assert!(render_cache_status(&leftovers[1..], CacheScope::All, Format::Text).ends_with(
2273            "1 leftover file (70 bytes) is fdu's own, left by an interrupted write; \
2274                 fdu --cache-clear=all reclaims it."
2275        ));
2276
2277        let root = [cache_file("a.fdu", 10, CacheState::Stale(StaleReason::OtherEngine))];
2278        assert!(render_cache_status(&root, CacheScope::Root, Format::Text).ends_with(
2279            "1 stale snapshot (15 bytes) cannot be served by this build; \
2280                 fdu --cache-clear PATH removes it."
2281        ));
2282
2283        let current = cache_file(
2284            "e.fdu",
2285            50,
2286            CacheState::Current(crate::SnapshotInfo {
2287                root: PathBuf::from("/tree"),
2288                identity: small_snapshot_identity(),
2289                entries: 3,
2290            }),
2291        );
2292        let mixed = [stale[0].clone(), current, stale[4].clone(), stale[4].clone()];
2293        assert!(render_cache_status(&mixed, CacheScope::All, Format::Text).ends_with(
2294            "e.fdu  3 entries, 50 metadata bytes, 0 content bytes  /tree\n\
2295             notes.txt  unrecognized, 14 bytes\n\
2296             notes.txt  unrecognized, 14 bytes\n\
2297             1 stale snapshot (15 bytes) cannot be served by this build; \
2298             fdu --cache-clear=all removes it, along with every current snapshot.\n\
2299             2 unrecognized files (28 bytes) are not fdu snapshots, so fdu leaves them in place."
2300        ));
2301
2302        let absent = [cache_file("f.fdu", 0, CacheState::Absent)];
2303        assert_eq!(
2304            render_cache_status(&absent, CacheScope::Root, Format::Text),
2305            "No cached snapshots."
2306        );
2307        // Every row carries the same keys whatever its state, and the envelope line
2308        // carries the schema even when nothing follows it.
2309        assert_eq!(
2310            render_cache_status(
2311                &[stale[0].clone(), absent[0].clone(), stale[4].clone(), leftovers[0].clone()],
2312                CacheScope::All,
2313                Format::Jsonl
2314            ),
2315            "{\"schema\": \"fdu.cache/2\"}\n\
2316             {\"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\
2317             {\"path\": \"f.fdu\", \"bytes\": 0, \"state\": \"absent\", \"content\": null}\n\
2318             {\"path\": \"notes.txt\", \"bytes\": 14, \"state\": \"unrecognized\", \"content\": null}\n\
2319             {\"path\": \".g.fdu.tmp.1.2.3\", \"bytes\": 60, \"state\": \"leftover\", \"leftover_kind\": \"staging_temporary\", \"content\": null}"
2320        );
2321        assert_eq!(
2322            render_cache_status(&[], CacheScope::All, Format::Jsonl),
2323            "{\"schema\": \"fdu.cache/2\"}"
2324        );
2325        assert_eq!(
2326            render_cache_status(&[], CacheScope::All, Format::Json),
2327            "{\n  \"schema\": \"fdu.cache/2\",\n  \"caches\": []\n}\n"
2328        );
2329        // An empty sequence in both formats: a bare `caches:` is YAML null, and a reader
2330        // of one schema should not have to tell null from a list it can iterate.
2331        assert_eq!(
2332            render_cache_status(&[], CacheScope::All, Format::Yaml),
2333            "schema: fdu.cache/2\ncaches: []\n"
2334        );
2335        assert!(
2336            render_cache_status(&stale[2..3], CacheScope::All, Format::Json)
2337                .starts_with("{\n  \"schema\": \"fdu.cache/2\",\n  \"caches\": [\n    {")
2338        );
2339        assert!(
2340            render_cache_status(&stale[2..3], CacheScope::All, Format::Yaml)
2341                .starts_with("schema: fdu.cache/2\ncaches:\n  -\n    path: c.fdu")
2342        );
2343        assert!(
2344            render_cache_status(&stale[2..3], CacheScope::All, Format::Yaml).ends_with(
2345                "state: stale\n    stale_reason: other_engine\n    format_version: null\n    \
2346                 content:\n      bytes: 5\n      state: stale\n      stale_reason: older_format\n      \
2347                 format_version: 4\n"
2348            )
2349        );
2350        assert!(render_cache_status(&leftovers[1..], CacheScope::All, Format::Yaml).ends_with(
2351            "state: leftover\n    leftover_kind: orphaned_content\n    content: null\n"
2352        ));
2353    }
2354
2355    /// A current snapshot carries the identity of every tier it holds, and the sidecar
2356    /// beside it its own, nested the same way in JSON and YAML: the entry tier, then the
2357    /// control tier as the report's `ignore_rules` names it, and for content its entry tier,
2358    /// which alone holds the type rules, then the analyzer set, options, and analyzers under
2359    /// the names a report's `analysis` object gives them.
2360    #[test]
2361    fn cache_status_carries_every_stored_tier_identity() {
2362        use crate::{CacheScope, CacheState, ContentInfo, ContentState, ContentStatus};
2363
2364        let snapshot = small_snapshot_identity();
2365        let content = crate::ContentTierIdentity {
2366            entries: snapshot.entries,
2367            analysis: crate::content::AnalysisSet::NONE.with_lines(),
2368            provenance: crate::AnalyzerProvenance {
2369                options_fingerprint: crate::content::OptionsFingerprint(5),
2370                analyzers: vec![(
2371                    crate::content::CONTENT_BASIC,
2372                    crate::content::AnalyzerVersion(1),
2373                )],
2374            },
2375        };
2376        let status = crate::CacheStatus {
2377            path: PathBuf::from("e.fdu"),
2378            bytes: 50,
2379            content: Some(ContentStatus {
2380                bytes: 9,
2381                state: ContentState::Current(ContentInfo { identity: content, records: 2 }),
2382            }),
2383            state: CacheState::Current(crate::SnapshotInfo {
2384                root: PathBuf::from("/tree"),
2385                identity: snapshot,
2386                entries: 3,
2387            }),
2388        };
2389        let entries = "{\"engine\": 1, \"max_depth\": null, \"follow_symlinks\": false, \
2390                       \"one_filesystem\": true, \"hidden_fingerprint\": 2, \"exclude_special\": false, \
2391                       \"type_rules_fingerprint\": 3, \"reducers_fingerprint\": 4}";
2392        assert_eq!(
2393            render_cache_status(std::slice::from_ref(&status), CacheScope::Root, Format::Jsonl),
2394            format!(
2395                "{{\"schema\": \"fdu.cache/2\"}}\n\
2396                 {{\"path\": \"e.fdu\", \"bytes\": 50, \"state\": \"current\", \"root\": \"/tree\", \
2397                 \"entries\": 3, \"identity\": {{\"entries\": {entries}, \"ignore_rules\": \
2398                 {{\"limits\": {{\"budget\": 10, \"line_limit\": null}}}}}}, \"content\": {{\"bytes\": 9, \
2399                 \"state\": \"current\", \"records\": 2, \"identity\": {{\"entries\": {entries}, \
2400                 \"analyze\": [\"lines\"], \"options_fingerprint\": 5, \
2401                 \"analyzers\": [{{\"id\": \"content-basic-v1\", \"version\": 1}}]}}}}}}"
2402            )
2403        );
2404        let entries = "\n          engine: 1\n          max_depth: null\n          \
2405                       follow_symlinks: false\n          one_filesystem: true\n          \
2406                       hidden_fingerprint: 2\n          exclude_special: false\n          \
2407                       type_rules_fingerprint: 3\n          reducers_fingerprint: 4";
2408        assert_eq!(
2409            render_cache_status(std::slice::from_ref(&status), CacheScope::Root, Format::Yaml),
2410            format!(
2411                "schema: fdu.cache/2\ncaches:\n  -\n    path: e.fdu\n    bytes: 50\n    state: current\n    \
2412                 root: /tree\n    entries: 3\n    identity:\n      entries:{}\n      \
2413                 ignore_rules:\n        limits:\n          budget: 10\n          line_limit: null\n    \
2414                 content:\n      bytes: 9\n      state: current\n      records: 2\n      identity:\n        \
2415                 entries:{entries}\n        analyze:\n          - lines\n        \
2416                 options_fingerprint: 5\n        analyzers:\n          -\n            id: content-basic-v1\n            \
2417                 version: 1\n",
2418                entries.replace("\n  ", "\n")
2419            )
2420        );
2421        // A sidecar this build cannot serve is named in text too.
2422        let stale_content = crate::CacheStatus {
2423            content: Some(ContentStatus {
2424                bytes: 9,
2425                state: ContentState::Stale(crate::StaleReason::OtherEngine),
2426            }),
2427            ..status
2428        };
2429        assert_eq!(
2430            render_cache_status(&[stale_content], CacheScope::Root, Format::Text),
2431            "e.fdu  3 entries, 50 metadata bytes, 9 stale content bytes  /tree"
2432        );
2433    }
2434
2435    /// The cache schema is a promise, like the report schema beside it.
2436    ///
2437    /// Fails loudly when the string moves, so the field rename this constant was added
2438    /// for — `recognized` to `state` — cannot happen again without a version to key on.
2439    #[test]
2440    fn the_cache_schema_constant_is_the_versioning_promise() {
2441        assert_eq!(CACHE_SCHEMA, "fdu.cache/2");
2442        for format in [Format::Json, Format::Jsonl, Format::Yaml] {
2443            let rendered = render_cache_status(&[], crate::CacheScope::All, format);
2444            assert!(rendered.contains(CACHE_SCHEMA), "{format:?} carries no schema: {rendered}");
2445        }
2446    }
2447
2448    #[cfg(all(unix, feature = "watch"))]
2449    #[test]
2450    fn cache_and_change_rows_preserve_non_unicode_raw_paths() {
2451        use std::ffi::OsString;
2452        use std::os::unix::ffi::OsStringExt;
2453
2454        let path = PathBuf::from(OsString::from_vec(vec![b'n', 0x80]));
2455        let status = crate::CacheStatus {
2456            path: path.clone(),
2457            bytes: 3,
2458            content: None,
2459            state: crate::CacheState::Unrecognized,
2460        };
2461        for format in [Format::Json, Format::Jsonl, Format::Yaml] {
2462            let rendered =
2463                render_cache_status(std::slice::from_ref(&status), crate::CacheScope::All, format);
2464            assert!(rendered.contains("path_raw"), "{format:?}: {rendered}");
2465            assert!(rendered.contains("6e80"), "{format:?}: {rendered}");
2466        }
2467
2468        let change = crate::Change {
2469            path,
2470            kind: crate::ChangeKind::Remove,
2471            entry_kind: None,
2472            bytes: None,
2473            allocated: None,
2474            mtime_ns: None,
2475            ignored: None,
2476            clock: 1,
2477        };
2478        for format in [Format::Json, Format::Jsonl, Format::Yaml] {
2479            let rendered = render_change(&change, format);
2480            assert!(rendered.contains("path_raw"), "{format:?}: {rendered}");
2481            assert!(rendered.contains("6e80"), "{format:?}: {rendered}");
2482        }
2483    }
2484
2485    /// A bound states itself, and the count it states is the count it dropped.
2486    ///
2487    /// The second half is why this is a unit test: a golden fixture is too small for a
2488    /// wrong total to look wrong, and the defect being guarded against — twenty rows of
2489    /// 192,871 presented as everything — only shows at a scale goldens do not reach.
2490    #[test]
2491    fn a_bound_states_itself_and_states_it_accurately() {
2492        let report = fixture(&[ViewSpec::Files]);
2493        let Section::Files { rows, total, .. } = &report.sections[0] else {
2494            panic!("expected a files section");
2495        };
2496        let full = rows.len();
2497        assert_eq!(*total, full, "an unbounded view drops nothing");
2498        assert!(!render(&report, Format::Text, false).contains("--limit all"), "and says nothing");
2499        assert!(render(&report, Format::Json, false).contains("\"bound\": null"));
2500
2501        // Now bound it to one row and check the report agrees with reality.
2502        let mut query = Query { views: vec![ViewSpec::Files], ..Query::default() };
2503        query.selection.limit = Some(Bound::Limit(1));
2504        let bounded = fixture_for(&query);
2505        let Section::Files { rows, total, .. } = &bounded.sections[0] else {
2506            panic!("expected a files section");
2507        };
2508        assert_eq!(rows.len(), 1);
2509        assert_eq!(*total, full, "the total is what there was, not what was kept");
2510
2511        let text = render(&bounded, Format::Text, false);
2512        assert!(text.contains(&format!("(1 of {full}")), "the header states the bound: {text}");
2513        assert!(text.contains("--limit all"), "and names the flag that lifts it: {text}");
2514        let json = render(&bounded, Format::Json, false);
2515        assert!(json.contains(&format!("\"shown\": 1, \"total\": {full}")), "{json:.200}");
2516        let yaml = render(&bounded, Format::Yaml, false);
2517        assert!(yaml.contains("shown: 1"), "{yaml:.200}");
2518    }
2519
2520    /// Colour must not move anything.
2521    ///
2522    /// The golden suite structurally cannot check this: it runs under `NO_COLOR=1` and
2523    /// only ever sees the uncoloured form, which is exactly how the extensions view
2524    /// shipped misaligned — `{:<12}` counted the escape sequences toward the field width,
2525    /// so the padding collapsed the moment colour was on and every golden still passed.
2526    #[test]
2527    fn colour_never_changes_the_layout_of_any_view() {
2528        fn strip_ansi(text: &str) -> String {
2529            let mut out = String::with_capacity(text.len());
2530            let mut chars = text.chars();
2531            while let Some(c) = chars.next() {
2532                if c == '\u{1b}' {
2533                    for escape in chars.by_ref() {
2534                        if escape.is_ascii_alphabetic() {
2535                            break;
2536                        }
2537                    }
2538                } else {
2539                    out.push(c);
2540                }
2541            }
2542            out
2543        }
2544
2545        for view in [
2546            ViewSpec::Tree,
2547            ViewSpec::Extensions,
2548            ViewSpec::Types,
2549            ViewSpec::Families,
2550            ViewSpec::Languages,
2551            ViewSpec::Files,
2552            ViewSpec::Summary,
2553        ] {
2554            let report = fixture(&[view]);
2555            let plain = render(&report, Format::Text, false);
2556            let coloured = render(&report, Format::Text, true);
2557            // Two views carry no label to style: `files` is a bare listing of paths meant
2558            // for piping, and `summary` is one aggregate line. Everything that draws a
2559            // label draws it styled, and this catches a view that quietly stops.
2560            let styles_a_label = !matches!(view, ViewSpec::Files | ViewSpec::Summary);
2561            assert_eq!(
2562                plain != coloured,
2563                styles_a_label,
2564                "{view:?} disagrees with whether it styles a label"
2565            );
2566            assert_eq!(
2567                strip_ansi(&coloured),
2568                plain,
2569                "{view:?} lays out differently once colour is on"
2570            );
2571        }
2572    }
2573
2574    /// The rule the helper exists to enforce, stated directly.
2575    #[test]
2576    fn a_label_cell_is_measured_on_visible_text() {
2577        let plain = label_cell("md", 6, STYLE_TYPE, false);
2578        let coloured = label_cell("md", 6, STYLE_TYPE, true);
2579        assert_eq!(plain, "md    ", "four spaces of padding");
2580        assert!(coloured.starts_with('\u{1b}'), "the label is styled");
2581        assert!(coloured.ends_with("    "), "and padded by the same four: {coloured:?}");
2582        // A label at or past the width gets no padding rather than a negative one.
2583        assert_eq!(label_cell("verylonglabel", 4, STYLE_TYPE, false), "verylonglabel");
2584    }
2585
2586    /// Every view, so a matrix test cannot silently skip one that was added later.
2587    const ALL_TEST_VIEWS: [ViewSpec; 11] = [
2588        ViewSpec::List,
2589        ViewSpec::Tree,
2590        ViewSpec::Types,
2591        ViewSpec::Extensions,
2592        ViewSpec::Families,
2593        ViewSpec::Languages,
2594        ViewSpec::Documents,
2595        ViewSpec::Files,
2596        ViewSpec::Largest,
2597        ViewSpec::Recent,
2598        ViewSpec::Summary,
2599    ];
2600
2601    /// Check a walk against its declaration without parsing or trusting a writer.
2602    struct SchemaCheck<S> {
2603        inner: S,
2604        expected: Vec<&'static str>,
2605        next: usize,
2606        depth: usize,
2607    }
2608
2609    impl<S: Sink> SchemaCheck<S> {
2610        fn report(inner: S, lossy: bool, sections: bool) -> Self {
2611            let fields = &REPORT_FIELDS;
2612            let ordered = [
2613                fields.schema,
2614                fields.generator,
2615                fields.root,
2616                fields.root_raw,
2617                fields.age_reference_ns,
2618                fields.request,
2619                fields.status,
2620                fields.provenance,
2621                fields.ignore_rules,
2622                fields.analysis,
2623                fields.reports,
2624            ];
2625            let expected = ordered
2626                .into_iter()
2627                .filter_map(|field| {
2628                    let present = match field.presence {
2629                        Presence::Always | Presence::Nullable => true,
2630                        Presence::WhenLossy => lossy,
2631                        Presence::WhenSet => sections,
2632                        Presence::WhenAnalyzer(_) => panic!("envelope has no analyzer-owned field"),
2633                    };
2634                    present.then_some(field.name)
2635                })
2636                .collect();
2637            Self { inner, expected, next: 0, depth: 0 }
2638        }
2639    }
2640
2641    impl<S: Sink> Sink for SchemaCheck<S> {
2642        type Output = S::Output;
2643
2644        fn event(&mut self, event: Event<'_>) {
2645            match event {
2646                Event::BeginMap(_) | Event::BeginSeq(_) => self.depth += 1,
2647                Event::EndMap | Event::EndSeq => self.depth -= 1,
2648                Event::Key(name) if self.depth == 1 => {
2649                    assert_eq!(
2650                        Some(&name),
2651                        self.expected.get(self.next),
2652                        "wire field order/presence"
2653                    );
2654                    self.next += 1;
2655                }
2656                Event::Key(_) | Event::Scalar(_) => {}
2657            }
2658            self.inner.event(event);
2659        }
2660
2661        fn finish(self) -> Self::Output {
2662            assert_eq!(self.depth, 0, "unclosed collection");
2663            assert_eq!(self.next, self.expected.len(), "required field missing");
2664            self.inner.finish()
2665        }
2666    }
2667
2668    #[test]
2669    fn report_walk_obeys_declared_field_order_and_presence_for_every_writer() {
2670        for view in [ViewSpec::Summary, ViewSpec::Documents] {
2671            let report = fixture(&[view]);
2672            for sections in [false, true] {
2673                let mut json = SchemaCheck::report(JsonSink::pretty(), false, sections);
2674                emit_report(&mut json, &report, sections);
2675                assert!(!json.finish().is_empty());
2676                let mut line = SchemaCheck::report(JsonSink::line(), false, sections);
2677                emit_report(&mut line, &report, sections);
2678                assert!(!line.finish().is_empty());
2679                let mut yaml = SchemaCheck::report(YamlSink::new(), false, sections);
2680                emit_report(&mut yaml, &report, sections);
2681                assert!(!yaml.finish().is_empty());
2682            }
2683        }
2684    }
2685
2686    #[test]
2687    #[should_panic(expected = "required field missing")]
2688    fn schema_check_rejects_a_missing_required_field() {
2689        let mut check = SchemaCheck::report(JsonSink::line(), false, true);
2690        check.event(Event::BeginMap(Shape::Inline));
2691        check.event(Event::EndMap);
2692        check.finish();
2693    }
2694
2695    #[test]
2696    fn list_formats_preserve_the_default_tree_and_expose_flat_subtree_metrics() {
2697        let legacy = fixture(&[ViewSpec::Tree]);
2698        let list = fixture(&[ViewSpec::List]);
2699        assert_eq!(
2700            super::render(&legacy, Format::Text, false).expect("tree"),
2701            super::render(&list, Format::Tree, false).expect("list tree")
2702        );
2703        assert!(
2704            super::render(&list, Format::Paths, false).is_err(),
2705            "folding cannot silently become an inventory"
2706        );
2707        let mut rejected = Vec::new();
2708        assert_eq!(
2709            write(&list, Format::Paths, false, &mut rejected)
2710                .expect_err("folded projection")
2711                .kind(),
2712            io::ErrorKind::InvalidInput
2713        );
2714        assert!(rejected.is_empty(), "validate before writing any bytes");
2715        let flat = fixture_for(&Query {
2716            views: vec![ViewSpec::List],
2717            format: Format::Paths,
2718            selection: Selection {
2719                kinds: vec![EntryKind::Dir],
2720                size: SizeMetric::Apparent,
2721                ..Selection::default()
2722            },
2723            ..Query::default()
2724        });
2725        assert_eq!(super::render(&flat, Format::Paths, false).expect("paths"), "src\n");
2726        assert!(super::render(&flat, Format::Long, false).expect("long").contains("100 B"));
2727        assert!(super::render(&flat, Format::Tree, false).is_err());
2728        let Section::Files { rows, .. } = &flat.sections[0] else { panic!("flat list") };
2729        assert_eq!((rows[0].files, rows[0].dirs, rows[0].mtime_ns), (Some(1), Some(0), 10));
2730        assert_eq!(rows[0].complete, Some(true));
2731        assert!(
2732            super::render(&flat, Format::Json, false).expect("json").contains("\"complete\": true")
2733        );
2734        assert_eq!(
2735            rows[0].age_ns,
2736            flat.age_reference_ns.map(|reference| i128::from(reference) - 10)
2737        );
2738        for format in [Format::Json, Format::Jsonl, Format::Yaml] {
2739            let wire = super::render(&flat, format, false).expect("serialization");
2740            assert!(wire.contains("age_reference_ns"));
2741            assert!(wire.contains("age_ns"));
2742        }
2743        assert_eq!(human_age(Some(-1)), "-0s");
2744        assert_eq!(human_age(Some(30 * 86400 * 1_000_000_000)), "30d");
2745        assert_eq!(human_age(None), "unknown");
2746        assert_eq!(flat_path(Path::new("a\nb\tc")), "a\\nb\\tc");
2747        // A backslash is the Windows separator; escaping it would print a path that
2748        // does not exist, so it is written as it is on every platform.
2749        assert_eq!(flat_path(Path::new("d/a\\b")), "d/a\\b");
2750        let mut stale = flat.clone();
2751        stale.provenance.source = ReportSource::CacheOnly;
2752        stale.provenance.freshness = Freshness::Stale;
2753        stale.scope.max_depth = Some(2);
2754        let notes = flat_diagnostics(&stale).join("\n");
2755        assert!(notes.contains("not been revalidated"));
2756        assert!(notes.contains("freshness: stale"));
2757        assert!(notes.contains("scan scope limited to depth 2"));
2758        assert_eq!(super::render(&stale, Format::Paths, false).expect("paths"), "src\n");
2759    }
2760
2761    #[test]
2762    fn signed_ages_preserve_exact_endpoints_in_streaming_machine_formats() {
2763        let mut report = fixture_for(&Query {
2764            views: vec![ViewSpec::List],
2765            format: Format::Json,
2766            ..Query::default()
2767        });
2768        for (reference, modified) in [(i64::MAX, i64::MIN), (i64::MIN, i64::MAX)] {
2769            let age = i128::from(reference) - i128::from(modified);
2770            report.age_reference_ns = Some(reference);
2771            let Section::Files { rows, .. } = &mut report.sections[0] else { panic!("flat rows") };
2772            rows[0].mtime_ns = modified;
2773            rows[0].age_ns = Some(age);
2774            for format in [Format::Json, Format::Jsonl, Format::Yaml] {
2775                let rendered = super::render(&report, format, false).expect("machine format");
2776                assert!(rendered.contains(&age.to_string()), "{format:?} lost exact signed age");
2777                let mut streamed = Vec::new();
2778                write(&report, format, false, &mut streamed).expect("streaming writer");
2779                assert_eq!(streamed, rendered.as_bytes());
2780            }
2781        }
2782    }
2783
2784    fn fixture(views: &[ViewSpec]) -> Report {
2785        fixture_for(&Query { views: views.to_vec(), ..Query::default() })
2786    }
2787
2788    fn fixture_for(query: &Query) -> Report {
2789        let mut index = Index::new_with_scope("/root", ScanScope::default());
2790        // A documents view is an answer about analyzers, so the index it is rendered from
2791        // holds one: the request model refuses that view over an index with no content tier,
2792        // whichever door the request came through. `words` and not `all`, because the code
2793        // analyzer would move the languages view's share off bytes.
2794        if query.views.contains(&ViewSpec::Documents) {
2795            index.prepare_content_analysis(crate::content::AnalysisRequest {
2796                profile: crate::content::AnalysisSet::NONE.with_words(),
2797                ..crate::content::AnalysisRequest::default()
2798            });
2799        }
2800        index
2801            .apply(&Observation::new(vec![
2802                Op::Upsert {
2803                    path: PathBuf::from("src"),
2804                    kind: EntryKind::Dir,
2805                    attrs: Attrs::default(),
2806                },
2807                Op::Upsert {
2808                    path: PathBuf::from("src/main.rs"),
2809                    kind: EntryKind::File,
2810                    attrs: attrs(100, 10),
2811                },
2812                Op::Upsert {
2813                    path: PathBuf::from("notes.md"),
2814                    kind: EntryKind::File,
2815                    attrs: attrs(20, 20),
2816                },
2817            ]))
2818            .expect("apply");
2819        report(
2820            &index,
2821            &crate::test_support::read_of(&index, query.clone()),
2822            &Provenance {
2823                scan_started_at: Some(UNIX_EPOCH + Duration::from_secs(1_786_386_151)),
2824                generated_at: UNIX_EPOCH + Duration::from_secs(1_786_386_152),
2825                source: ReportSource::ColdScan,
2826                complete: true,
2827                errors: Vec::new(),
2828            },
2829        )
2830        .expect("report")
2831    }
2832
2833    /// Whether a rendered line is a view header rather than a data row.
2834    ///
2835    /// Blank lines are excluded explicitly: `all` is vacuously true on an empty line, so
2836    /// the separator between blocks would otherwise count as a header.
2837    fn is_view_header_line(line: &str) -> bool {
2838        !line.is_empty() && line.chars().all(|c| c.is_ascii_uppercase())
2839    }
2840
2841    /// A structural check that output is well-formed JSON.
2842    ///
2843    /// Hand-written serializers earn their keep only if something proves they balance, so
2844    /// this walks the text tracking nesting depth and string state.
2845    fn is_valid_json(text: &str) -> bool {
2846        let (mut depth, mut in_string, mut escaped) = (0i32, false, false);
2847        for ch in text.chars() {
2848            if in_string {
2849                match ch {
2850                    _ if escaped => escaped = false,
2851                    '\\' => escaped = true,
2852                    '"' => in_string = false,
2853                    _ => {}
2854                }
2855                continue;
2856            }
2857            match ch {
2858                '"' => in_string = true,
2859                '{' | '[' => depth += 1,
2860                '}' | ']' => {
2861                    depth -= 1;
2862                    if depth < 0 {
2863                        return false;
2864                    }
2865                }
2866                _ => {}
2867            }
2868        }
2869        depth == 0 && !in_string
2870    }
2871
2872    /// Remove insignificant JSON whitespace without touching string contents.
2873    ///
2874    /// Exact layout is intentionally allowed to change when the structural sink changes;
2875    /// field names, values, and ordering remain part of the wire promise.
2876    fn compact_json(text: &str) -> String {
2877        let mut compact = String::with_capacity(text.len());
2878        let (mut in_string, mut escaped) = (false, false);
2879        for ch in text.chars() {
2880            if in_string {
2881                compact.push(ch);
2882                match ch {
2883                    _ if escaped => escaped = false,
2884                    '\\' => escaped = true,
2885                    '"' => in_string = false,
2886                    _ => {}
2887                }
2888            } else if ch == '"' {
2889                in_string = true;
2890                compact.push(ch);
2891            } else if !ch.is_ascii_whitespace() {
2892                compact.push(ch);
2893            }
2894        }
2895        compact
2896    }
2897
2898    /// Formats are serializations, not features: no view may lack one.
2899    ///
2900    /// Driven from `ALL_TEST_VIEWS` rather than a hand-written list, because a list is
2901    /// exactly what goes stale — `largest` and `recent` would not have been in it.
2902    #[test]
2903    fn every_view_renders_in_every_format() {
2904        for view in ALL_TEST_VIEWS {
2905            let report = fixture(&[view]);
2906            for format in [Format::Text, Format::Json, Format::Jsonl, Format::Yaml] {
2907                let rendered = render(&report, format, false);
2908                assert!(!rendered.trim().is_empty(), "{view:?} in {format:?} rendered nothing");
2909                if format != Format::Text {
2910                    assert!(
2911                        rendered.contains("\"schema\"") || rendered.contains("schema:"),
2912                        "{view:?} as {format:?} carries no schema: {rendered:.120}"
2913                    );
2914                }
2915            }
2916        }
2917    }
2918
2919    #[test]
2920    fn text_tree_restores_compact_bars_and_keeps_structural_indent_in_the_name_column() {
2921        // Apparent, so both rows print sizes of one width and the alignment below is about
2922        // the layout rather than about which sizes happen to round to the same block.
2923        let apparent = Query {
2924            views: vec![ViewSpec::Tree],
2925            selection: crate::query::Selection {
2926                size: crate::query::SizeMetric::Apparent,
2927                ..crate::query::Selection::default()
2928            },
2929            ..Query::default()
2930        };
2931        let text = render(&fixture_for(&apparent), Format::Text, false);
2932        assert_eq!(
2933            text,
2934            concat!(
2935                "     120 B  ██████████   100%  . (2 files)\n",
2936                "     100 B  ████████░░    83%    src (1 file)\n",
2937            )
2938        );
2939
2940        let lines: Vec<&str> = text.lines().collect();
2941        assert_eq!(lines[0].find("120 B"), lines[1].find("100 B"));
2942        assert_eq!(lines[0].find('█'), lines[1].find('█'));
2943    }
2944
2945    #[test]
2946    fn language_text_uses_human_names_and_aligns_suffixes_with_color() {
2947        let mut index = Index::new_with_scope("/root", ScanScope::default());
2948        index
2949            .apply(&Observation::new(vec![
2950                Op::Upsert {
2951                    path: PathBuf::from("main.cpp"),
2952                    kind: EntryKind::File,
2953                    attrs: attrs(100, 10),
2954                },
2955                Op::Upsert {
2956                    path: PathBuf::from("main.js"),
2957                    kind: EntryKind::File,
2958                    attrs: attrs(100, 20),
2959                },
2960            ]))
2961            .expect("apply");
2962        let report = report(
2963            &index,
2964            &crate::test_support::read_of(
2965                &index,
2966                Query { views: vec![ViewSpec::Languages], ..Query::default() },
2967            ),
2968            &Provenance {
2969                scan_started_at: None,
2970                generated_at: UNIX_EPOCH,
2971                source: ReportSource::ColdScan,
2972                complete: true,
2973                errors: Vec::new(),
2974            },
2975        )
2976        .expect("report");
2977
2978        let plain = render(&report, Format::Text, false);
2979        assert!(plain.contains("C++"), "{plain}");
2980        assert!(plain.contains("JavaScript"), "{plain}");
2981        let plain_suffixes = plain
2982            .lines()
2983            .map(|line| line.find("1 file").expect("file count suffix"))
2984            .collect::<Vec<_>>();
2985        assert_eq!(plain_suffixes[0], plain_suffixes[1], "{plain}");
2986
2987        let colored = render(&report, Format::Text, true);
2988        let colored_suffixes = colored
2989            .lines()
2990            .map(|line| line.find("1 file").expect("colored file count suffix"))
2991            .collect::<Vec<_>>();
2992        assert_eq!(colored_suffixes[0], colored_suffixes[1], "{colored:?}");
2993
2994        let json = render(&report, Format::Json, false);
2995        assert!(json.contains("\"id\": \"cpp\""), "{json}");
2996        assert!(json.contains("\"id\": \"javascript\""), "{json}");
2997        assert!(!json.contains("\"id\": \"C++\""), "{json}");
2998        assert!(!json.contains("\"id\": \"JavaScript\""), "{json}");
2999    }
3000
3001    #[test]
3002    fn text_labels_a_percentage_that_is_not_a_byte_share() {
3003        let mut languages = fixture(&[ViewSpec::Languages]);
3004        if let Section::Metrics { summary, .. } = &mut languages.sections[0] {
3005            summary.share_metric = ShareMetric::CodeLines;
3006        } else {
3007            panic!("languages should be a metric section");
3008        }
3009        let text = render(&languages, Format::Text, false);
3010        assert!(text.starts_with("Percentage column: code lines\n"), "{text}");
3011
3012        let Section::Metrics { summary, .. } = &mut languages.sections[0] else {
3013            unreachable!("languages should stay a metric section");
3014        };
3015        summary.share_metric = ShareMetric::AllocatedBytes;
3016        let text = render(&languages, Format::Text, false);
3017        assert!(!text.contains("Percentage column:"), "{text}");
3018
3019        let mut documents = fixture(&[ViewSpec::Documents]);
3020        let Section::Metrics { summary, .. } = &mut documents.sections[0] else {
3021            panic!("documents should be a metric section");
3022        };
3023        summary.share_metric = ShareMetric::DocumentWords;
3024        let text = render(&documents, Format::Text, false);
3025        assert!(text.starts_with("Percentage column: document words\n"), "{text}");
3026    }
3027
3028    #[test]
3029    fn json_output_is_well_formed_for_every_view() {
3030        for view in [
3031            ViewSpec::Tree,
3032            ViewSpec::Extensions,
3033            ViewSpec::Types,
3034            ViewSpec::Families,
3035            ViewSpec::Languages,
3036            ViewSpec::Documents,
3037            ViewSpec::Files,
3038            ViewSpec::Summary,
3039        ] {
3040            let json = render(&fixture(&[view]), Format::Json, false);
3041            assert!(is_valid_json(&json), "unbalanced JSON for {view:?}:\n{json}");
3042        }
3043        let all = render(
3044            &fixture(&[
3045                ViewSpec::Tree,
3046                ViewSpec::Extensions,
3047                ViewSpec::Types,
3048                ViewSpec::Files,
3049                ViewSpec::Summary,
3050            ]),
3051            Format::Json,
3052            false,
3053        );
3054        assert!(is_valid_json(&all), "unbalanced JSON for a multi-view report:\n{all}");
3055    }
3056
3057    #[test]
3058    fn streaming_machine_writers_match_string_rendering() {
3059        struct Fails;
3060        impl std::io::Write for Fails {
3061            fn write(&mut self, _buffer: &[u8]) -> std::io::Result<usize> {
3062                Err(std::io::Error::other("closed"))
3063            }
3064
3065            fn flush(&mut self) -> std::io::Result<()> {
3066                Ok(())
3067            }
3068        }
3069
3070        let report = fixture(&[
3071            ViewSpec::Tree,
3072            ViewSpec::Extensions,
3073            ViewSpec::Types,
3074            ViewSpec::Files,
3075            ViewSpec::Summary,
3076        ]);
3077        for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3078            let expected = render(&report, format, false);
3079            let mut streamed = Vec::new();
3080            write(&report, format, false, &mut streamed).expect("stream report");
3081            assert_eq!(streamed, expected.as_bytes(), "{format:?} bytes differ");
3082        }
3083
3084        let error = write(&report, Format::Json, false, &mut Fails).expect_err("writer fails");
3085        assert_eq!(error.kind(), std::io::ErrorKind::Other);
3086    }
3087
3088    #[test]
3089    fn streaming_tree_walk_handles_many_siblings_without_collecting_output() {
3090        #[derive(Default)]
3091        struct Count(u64);
3092        impl std::io::Write for Count {
3093            fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
3094                self.0 = self.0.saturating_add(buffer.len() as u64);
3095                Ok(buffer.len())
3096            }
3097
3098            fn flush(&mut self) -> std::io::Result<()> {
3099                Ok(())
3100            }
3101        }
3102
3103        let mut report = fixture(&[ViewSpec::Tree]);
3104        let Section::Tree { root, .. } = &mut report.sections[0] else {
3105            panic!("tree fixture must contain a tree");
3106        };
3107        let template = root.children[0].clone();
3108        root.children = (0..10_000)
3109            .map(|index| {
3110                let mut child = template.clone();
3111                child.name = format!("child-{index}");
3112                child.path = PathBuf::from(&child.name);
3113                child
3114            })
3115            .collect();
3116
3117        let mut output = Count::default();
3118        write(&report, Format::Json, false, &mut output).expect("stream wide report");
3119        assert!(output.0 > 1_000_000, "wide fixture must exercise substantial output");
3120    }
3121
3122    #[test]
3123    fn nested_json_separates_siblings_without_a_trailing_comma() {
3124        // The original fixture had no directory with two children, so a balanced-but-
3125        // invalid `[{a}{b},]` passed the structural check. Sibling separators need a
3126        // case that actually has siblings, at more than one level.
3127        let mut index = Index::new_with_scope("/root", ScanScope::default());
3128        index
3129            .apply(&Observation::new(vec![
3130                Op::Upsert {
3131                    path: PathBuf::from("a"),
3132                    kind: EntryKind::Dir,
3133                    attrs: Attrs::default(),
3134                },
3135                Op::Upsert {
3136                    path: PathBuf::from("b"),
3137                    kind: EntryKind::Dir,
3138                    attrs: Attrs::default(),
3139                },
3140                Op::Upsert {
3141                    path: PathBuf::from("c"),
3142                    kind: EntryKind::Dir,
3143                    attrs: Attrs::default(),
3144                },
3145                Op::Upsert {
3146                    path: PathBuf::from("a/inner"),
3147                    kind: EntryKind::Dir,
3148                    attrs: Attrs::default(),
3149                },
3150                Op::Upsert {
3151                    path: PathBuf::from("a/other"),
3152                    kind: EntryKind::Dir,
3153                    attrs: Attrs::default(),
3154                },
3155            ]))
3156            .expect("apply");
3157        let report = report(
3158            &index,
3159            &crate::test_support::read_of(
3160                &index,
3161                Query {
3162                    selection: Selection { depth: Some(Bound::All), ..Selection::default() },
3163                    views: vec![ViewSpec::Tree],
3164                    ..Query::default()
3165                },
3166            ),
3167            &Provenance {
3168                scan_started_at: None,
3169                generated_at: UNIX_EPOCH,
3170                source: ReportSource::ColdScan,
3171                complete: true,
3172                errors: Vec::new(),
3173            },
3174        )
3175        .expect("report");
3176
3177        let json = render(&report, Format::Json, false);
3178        assert!(is_valid_json(&json), "{json}");
3179        assert!(!json.contains("}{"), "siblings must be separated:\n{json}");
3180        assert!(!json.contains(",]"), "no trailing comma before a close:\n{json}");
3181        assert!(!json.contains("[,"), "no leading comma after an open:\n{json}");
3182        // Three top-level siblings and two nested ones must all be present.
3183        for name in ["\"a\"", "\"b\"", "\"c\"", "\"inner\"", "\"other\""] {
3184            assert!(json.contains(name), "missing {name} in:\n{json}");
3185        }
3186    }
3187
3188    #[test]
3189    fn jsonl_emits_one_document_per_line() {
3190        let rendered =
3191            render(&fixture(&[ViewSpec::Extensions, ViewSpec::Summary]), Format::Jsonl, false);
3192        let lines: Vec<&str> = rendered.lines().collect();
3193        assert_eq!(lines.len(), 3, "one envelope plus one line per section");
3194        for line in &lines {
3195            assert!(is_valid_json(line), "line is not a JSON document: {line}");
3196        }
3197        assert!(lines[0].contains("\"schema\""), "the envelope carries provenance");
3198    }
3199
3200    #[test]
3201    fn machine_output_carries_the_schema_and_provenance() {
3202        let json = render(&fixture(&[ViewSpec::Summary]), Format::Json, false);
3203        assert!(json.contains("\"schema\": \"fdu.report/7\""));
3204        assert!(json.contains("\"request\": {"));
3205        assert!(json.contains("\"status\": {"));
3206        assert!(json.contains("\"provenance\": {"));
3207        assert!(json.contains("\"source\": \"cold_scan\""));
3208        assert!(json.contains("\"complete\": true"));
3209        // Timestamps render in the same grammar the CLI accepts back as a watermark.
3210        assert!(json.contains("\"scan_started_at\": \"2026-08-10T18:22:31.000000000Z\""), "{json}");
3211        assert!(json.contains("\"generated_at\": \"2026-08-10T18:22:32.000000000Z\""), "{json}");
3212    }
3213
3214    #[test]
3215    fn the_schema_constant_is_the_versioning_promise() {
3216        // Fails loudly when the schema string moves, so a field rename cannot ship
3217        // without a deliberate version bump and a golden update.
3218        assert_eq!(REPORT_SCHEMA, "fdu.report/7");
3219        assert_eq!(CONTENT_REPORT_SCHEMA, REPORT_SCHEMA);
3220    }
3221
3222    /// Every format says whether ignore rules were read and which files were refused, and
3223    /// text names the directories and the knob as the requesting surface spells it.
3224    #[test]
3225    fn every_format_states_the_ignore_rules_a_report_could_apply() {
3226        let unobserved = crate::test_support::not_observing_controls();
3227        let mut blind = Index::new_with_scope("/root", unobserved);
3228        blind
3229            .apply(&Observation::new(vec![Op::Upsert {
3230                path: PathBuf::from("a.txt"),
3231                kind: EntryKind::File,
3232                attrs: attrs(1, 1),
3233            }]))
3234            .expect("apply");
3235        let provenance = Provenance {
3236            scan_started_at: None,
3237            generated_at: UNIX_EPOCH,
3238            source: ReportSource::ColdScan,
3239            complete: true,
3240            errors: Vec::new(),
3241        };
3242        let query = Query { views: vec![ViewSpec::Summary], ..Query::default() };
3243        let blind_report =
3244            report(&blind, &crate::test_support::read_of(&blind, query.clone()), &provenance)
3245                .expect("report");
3246        assert!(render(&blind_report, Format::Json, false).contains("\"ignore_rules\": null"));
3247        assert!(render(&blind_report, Format::Yaml, false).contains("\nignore_rules: null\n"));
3248        assert!(blind_report.notes.is_empty());
3249
3250        let mut observed =
3251            Index::new_with_scope("/root", crate::test_support::observing_controls());
3252        let mut long_line = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
3253        long_line.push(b'\n');
3254        observed
3255            .apply(&Observation::new(vec![
3256                Op::Upsert {
3257                    path: PathBuf::from("vendor"),
3258                    kind: EntryKind::Dir,
3259                    attrs: attrs(0, 1),
3260                },
3261                Op::ControlUpsert {
3262                    path: PathBuf::from(".gitignore"),
3263                    source: b"*.log\n".to_vec(),
3264                },
3265                Op::ControlUpsert { path: PathBuf::from("vendor/.gitignore"), source: long_line },
3266            ]))
3267            .expect("apply");
3268        // The platform spells the refused path, so Windows writes a backslash.
3269        let refused = Path::new("vendor").join(".gitignore");
3270        let refused = refused.to_string_lossy();
3271        let json = render(
3272            &report(
3273                &observed,
3274                &crate::test_support::read_of(&observed, query.clone()),
3275                &provenance,
3276            )
3277            .expect("report"),
3278            Format::Json,
3279            false,
3280        );
3281        let expected = format!(
3282            "\"ignore_rules\": {{\"limits\": {{\"budget\": 4194304, \"line_limit\": 16384}}, \
3283             \"applied\": 1, \"refused\": 1, \"refusals\": [{{\"path\": {}, \"reason\": \
3284             \"line_limit\"}}]}}",
3285            quote(&refused)
3286        );
3287        assert!(compact_json(&json).contains(&compact_json(&expected)), "{json}");
3288        assert!(json.contains("\"complete\": true"), "a refusal is not an operational partial");
3289        let yaml = render(
3290            &report(
3291                &observed,
3292                &crate::test_support::read_of(&observed, query.clone()),
3293                &provenance,
3294            )
3295            .expect("report"),
3296            Format::Yaml,
3297            false,
3298        );
3299        let expected = format!(
3300            "ignore_rules:\n  limits: {{budget: 4194304, line_limit: 16384}}\n  applied: 1\n  \
3301             refused: 1\n  refusals:\n    -\n      path: {}\n      reason: line_limit\n",
3302            yaml_scalar(&refused)
3303        );
3304        assert!(yaml.contains(&expected), "{yaml}");
3305
3306        let flags = Query { axes: &crate::query::AxisNames::FLAGS, ..query.clone() };
3307        let note = "note: 1 .gitignore file not applied (1 with a line over the 16 KiB line \
3308                    limit), so ignored shares under vendor are not exact; sizes are. To apply \
3309                    them, raise --gitignore-line-limit above 16 KiB, or set it to all";
3310        let text = render(
3311            &report(
3312                &observed,
3313                &crate::test_support::read_of(&observed, flags.clone()),
3314                &provenance,
3315            )
3316            .expect("report"),
3317            Format::Text,
3318            false,
3319        );
3320        assert!(text.ends_with(&format!("{note}\n")), "{text}");
3321        let fields =
3322            report(&observed, &crate::test_support::read_of(&observed, query.clone()), &provenance)
3323                .expect("report");
3324        assert_eq!(fields.notes, [note.replace("--gitignore-line-limit", "control_line_limit")]);
3325    }
3326
3327    /// Every row that carries an ignored share says so in every format: text appends it
3328    /// only when something is ignored and the selection is not ignored entries alone, and
3329    /// machine formats write a zero share when nothing is and `null` when no rule was read.
3330    #[test]
3331    fn every_format_carries_each_rows_ignored_share() {
3332        let build = |scope: ScanScope| {
3333            let mut index = Index::new_with_scope("/root", scope);
3334            let mut ops = vec![
3335                Op::Upsert {
3336                    path: PathBuf::from("dist"),
3337                    kind: EntryKind::Dir,
3338                    attrs: Attrs::default(),
3339                },
3340                Op::Upsert {
3341                    path: PathBuf::from("dist/a.gz"),
3342                    kind: EntryKind::File,
3343                    attrs: attrs(128, 10),
3344                },
3345                Op::Upsert {
3346                    path: PathBuf::from("src"),
3347                    kind: EntryKind::Dir,
3348                    attrs: Attrs::default(),
3349                },
3350                Op::Upsert {
3351                    path: PathBuf::from("src/b.rs"),
3352                    kind: EntryKind::File,
3353                    attrs: attrs(36, 20),
3354                },
3355            ];
3356            if scope.observes_controls() {
3357                ops.insert(
3358                    0,
3359                    Op::ControlUpsert {
3360                        path: PathBuf::from(".gitignore"),
3361                        source: b"dist/\n".to_vec(),
3362                    },
3363                );
3364            }
3365            index.apply(&Observation::new(ops)).expect("apply");
3366            index
3367        };
3368        let provenance = Provenance {
3369            scan_started_at: None,
3370            generated_at: UNIX_EPOCH,
3371            source: ReportSource::ColdScan,
3372            complete: true,
3373            errors: Vec::new(),
3374        };
3375        let views = vec![ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions, ViewSpec::Files];
3376        let query = |ignored| Query {
3377            views: views.clone(),
3378            selection: Selection {
3379                ignored,
3380                size: SizeMetric::Apparent,
3381                limit: Some(Bound::All),
3382                ..Selection::default()
3383            },
3384            ..Query::default()
3385        };
3386
3387        let observed = build(crate::test_support::observing_controls());
3388        let text = render(
3389            &report(
3390                &observed,
3391                &crate::test_support::read_of(&observed, query(IgnoredEntries::Include)),
3392                &provenance,
3393            )
3394            .expect("report"),
3395            Format::Text,
3396            false,
3397        );
3398        assert_eq!(
3399            text,
3400            concat!(
3401                "SUMMARY\n",
3402                "     164 B  2 files, 2 directories (128 B ignored)\n",
3403                "\n",
3404                "TREE\n",
3405                "     164 B  ██████████   100%  . (2 files) (128 B ignored)\n",
3406                "     128 B  ████████░░    78%    dist (1 file) (128 B ignored)\n",
3407                "      36 B  ██░░░░░░░░    22%    src (1 file)\n",
3408                "\n",
3409                "EXTENSIONS\n",
3410                "     128 B  .gz          1 file (128 B ignored)\n",
3411                "      36 B  .rs          1 file\n",
3412                "\n",
3413                "FILES\n",
3414                "dist\n",
3415                "dist/a.gz\n",
3416                "src\n",
3417                "src/b.rs\n",
3418            )
3419            .replace('/', std::path::MAIN_SEPARATOR_STR)
3420        );
3421        // A share of ignored directories alone holds no bytes, so text says nothing of it.
3422        let dirs_only = IgnoredTally { files: 0, dirs: 1, bytes: 0, allocated: 0 };
3423        assert_eq!(
3424            ignored_suffix(Some(dirs_only), SizeMetric::Apparent, IgnoredEntries::Include),
3425            ""
3426        );
3427        let only = render(
3428            &report(
3429                &observed,
3430                &crate::test_support::read_of(&observed, query(IgnoredEntries::Only)),
3431                &provenance,
3432            )
3433            .expect("report"),
3434            Format::Text,
3435            false,
3436        );
3437        assert!(only.contains("     128 B  1 file, 1 directory\n"), "{only}");
3438        assert!(!only.contains("ignored"), "every row is ignored, so none repeats it: {only}");
3439
3440        let json = render(
3441            &report(
3442                &observed,
3443                &crate::test_support::read_of(&observed, query(IgnoredEntries::Include)),
3444                &provenance,
3445            )
3446            .expect("report"),
3447            Format::Json,
3448            false,
3449        );
3450        assert!(is_valid_json(&json), "{json}");
3451        let compact = compact_json(&json);
3452        for expected in [
3453            "\"summary\": {\"files\": 2, \"dirs\": 2, \"bytes\": 164, \"allocated\": 1024, \
3454             \"ignored\": {\"files\": 1, \"dirs\": 1, \"bytes\": 128, \"allocated\": 512}, ",
3455            "\"name\": \"src\", \"path\": \"src\", \"kind\": \"dir\", \"bytes\": 36, \
3456             \"allocated\": 512, \"files\": 1, \"dirs\": 0, \"ignored\": {\"files\": 0, \
3457             \"dirs\": 0, \"bytes\": 0, \"allocated\": 0}, ",
3458            "{\"extension\": \".gz\", \"files\": 1, \"bytes\": 128, \"allocated\": 512, \
3459             \"ignored\": {\"files\": 1, \"bytes\": 128, \"allocated\": 512}}",
3460            "\"kind\": \"dir\", \"bytes\": 128, \"allocated\": 512, \"mtime_ns\": 10, \"files\": 1, \"dirs\": 0, \"complete\": true, \"age_ns\": -10, \"ignored\": true}",
3461        ] {
3462            assert!(compact.contains(&compact_json(expected)), "missing {expected}\nin {json}");
3463        }
3464        let yaml = render(
3465            &report(
3466                &observed,
3467                &crate::test_support::read_of(&observed, query(IgnoredEntries::Include)),
3468                &provenance,
3469            )
3470            .expect("report"),
3471            Format::Yaml,
3472            false,
3473        );
3474        assert!(
3475            yaml.contains(
3476                "      allocated: 1024\n      ignored: {files: 1, dirs: 1, bytes: 128, \
3477                 allocated: 512}\n      newest_mtime_ns: 20\n"
3478            ),
3479            "{yaml}"
3480        );
3481        assert!(yaml.contains("        ignored: true\n"), "{yaml}");
3482
3483        let blind = build(crate::test_support::not_observing_controls());
3484        let blind_report = report(
3485            &blind,
3486            &crate::test_support::read_of(&blind, query(IgnoredEntries::Include)),
3487            &provenance,
3488        )
3489        .expect("report");
3490        assert!(!render(&blind_report, Format::Text, false).contains("ignored"));
3491        let json = render(&blind_report, Format::Json, false);
3492        assert!(!json.contains("\"ignored\": {"), "never a zero share for an unread rule: {json}");
3493        assert!(json.contains("\"ignored\": null"), "{json}");
3494        assert!(render(&blind_report, Format::Yaml, false).contains("ignored: null\n"));
3495    }
3496
3497    #[test]
3498    fn every_report_uses_one_schema_and_states_nullable_analysis() {
3499        let metadata = render(&fixture(&[ViewSpec::Tree]), Format::Json, false);
3500        assert!(metadata.contains("\"schema\": \"fdu.report/7\""));
3501        assert!(metadata.contains("\"analysis\": null"));
3502
3503        let metrics = render(&fixture(&[ViewSpec::Types]), Format::Json, false);
3504        assert!(metrics.contains("\"schema\": \"fdu.report/7\""));
3505        assert!(metrics.contains("\"analysis\": null"));
3506        assert!(metrics.contains("\"share\": {\"numerator\":"));
3507    }
3508
3509    /// The change stream carries the same promise the report does.
3510    ///
3511    /// A constant assertion alone would not: it pins the version string while leaving the
3512    /// record's shape free to change underneath it, which is the failure the promise
3513    /// exists to prevent. This pins the whole record, so adding, renaming, or reordering
3514    /// a field fails here and forces a deliberate version bump.
3515    ///
3516    /// `ignored` was added to `fdu.stream/2` before the first release, so no consumer has
3517    /// ever read the earlier draft shape it extends.
3518    #[cfg(feature = "watch")]
3519    #[test]
3520    fn a_stream_record_is_pinned_field_by_field() {
3521        use crate::{Change, ChangeKind};
3522
3523        assert_eq!(STREAM_SCHEMA, "fdu.stream/2");
3524
3525        let upsert = Change {
3526            path: ["src", "main.rs"].iter().collect(),
3527            kind: ChangeKind::Upsert,
3528            entry_kind: Some(EntryKind::File),
3529            bytes: Some(2_048),
3530            allocated: Some(4_096),
3531            mtime_ns: Some(1_700_000_000_000_000_000),
3532            ignored: Some(false),
3533            clock: 7,
3534        };
3535        // Path separators differ by platform, so the expectation is built the same way
3536        // the renderer builds it rather than hardcoding a slash.
3537        let path = upsert.path.to_string_lossy().replace('\\', "\\\\");
3538        assert_eq!(
3539            render_change(&upsert, Format::Json),
3540            format!(
3541                "{{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"upsert\", \
3542                 \"path\": \"{path}\", \"clock\": 7, \"kind\": \"file\", \"bytes\": 2048, \
3543                 \"allocated\": 4096, \"mtime_ns\": 1700000000000000000, \"ignored\": false}}"
3544            )
3545        );
3546
3547        // A run that read no ignore rules classifies nothing, and the field is absent
3548        // rather than false: the same distinction every report row draws.
3549        let unclassified = Change { ignored: None, ..upsert.clone() };
3550        assert_eq!(
3551            render_change(&unclassified, Format::Json),
3552            format!(
3553                "{{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"upsert\", \
3554                 \"path\": \"{path}\", \"clock\": 7, \"kind\": \"file\", \"bytes\": 2048, \
3555                 \"allocated\": 4096, \"mtime_ns\": 1700000000000000000}}"
3556            )
3557        );
3558
3559        // A removal has no metadata to report, and the optional fields must be absent
3560        // rather than null: a consumer distinguishes "gone" from "unknown" by their
3561        // absence.
3562        let removed = Change {
3563            path: PathBuf::from("gone.txt"),
3564            kind: ChangeKind::Remove,
3565            entry_kind: None,
3566            bytes: None,
3567            allocated: None,
3568            mtime_ns: None,
3569            ignored: None,
3570            clock: 8,
3571        };
3572        assert_eq!(
3573            render_change(&removed, Format::Json),
3574            "{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"remove\", \
3575             \"path\": \"gone.txt\", \"clock\": 8}"
3576        );
3577
3578        // A removal an ignore-rule edit caused is the one that carries a classification:
3579        // the entry is still on disk, and the new bit is why it left the selection.
3580        let reclassified =
3581            Change { path: PathBuf::from("debug.log"), ignored: Some(true), ..removed.clone() };
3582        assert_eq!(
3583            render_change(&reclassified, Format::Json),
3584            "{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"remove\", \
3585             \"path\": \"debug.log\", \"clock\": 8, \"ignored\": true}"
3586        );
3587
3588        // An invalidation says the consumer's view may have gaps. It is the one record
3589        // that must never be dropped, so its shape is pinned too.
3590        let invalidated = Change {
3591            path: PathBuf::from("subtree"),
3592            kind: ChangeKind::Invalidate,
3593            entry_kind: None,
3594            bytes: None,
3595            allocated: None,
3596            mtime_ns: None,
3597            ignored: None,
3598            clock: 9,
3599        };
3600        assert_eq!(
3601            render_change(&invalidated, Format::Json),
3602            "{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"invalidate\", \
3603             \"path\": \"subtree\", \"clock\": 9}"
3604        );
3605
3606        // Text is the greppable form: path first, operation second, tab-separated.
3607        assert_eq!(render_change(&removed, Format::Text), "gone.txt\tremove");
3608    }
3609
3610    #[test]
3611    fn a_files_view_prints_one_path_per_line_and_nothing_else() {
3612        // The property that makes `fdu --view files | xargs` work. It is why the view
3613        // header is conditional: a lone files view is a path listing, not a table that
3614        // needs labelling, so nothing is prepended to it.
3615        let text = render(&fixture(&[ViewSpec::Files]), Format::Text, false);
3616        for line in text.lines() {
3617            assert!(!line.contains(' '), "text files output must be bare paths, got {line:?}");
3618        }
3619        let expected: PathBuf = ["src", "main.rs"].iter().collect();
3620        let expected = expected.display().to_string();
3621        assert!(text.lines().any(|line| line == expected), "{text}");
3622    }
3623
3624    #[test]
3625    fn several_views_are_labelled_and_a_lone_view_is_left_bare() {
3626        // Concatenated blocks of similar-looking rows were the problem: a reader had to
3627        // recover which view produced which table from the order they were requested in.
3628        let text = render(
3629            &fixture(&[ViewSpec::Tree, ViewSpec::Types, ViewSpec::Summary]),
3630            Format::Text,
3631            false,
3632        );
3633        let headers: Vec<&str> = text.lines().filter(|line| is_view_header_line(line)).collect();
3634        assert_eq!(headers, ["TREE", "TYPES", "SUMMARY"], "{text}");
3635
3636        // Each header sits directly above the rows it labels, and one blank line
3637        // separates the blocks.
3638        let lines: Vec<&str> = text.lines().collect();
3639        for (index, line) in lines.iter().enumerate() {
3640            if headers.contains(line) {
3641                assert!(
3642                    lines.get(index + 1).is_some_and(|next| !next.is_empty()),
3643                    "header {line} must sit directly above its rows:\n{text}"
3644                );
3645                if index > 0 {
3646                    assert!(
3647                        lines[index - 1].is_empty(),
3648                        "a blank line must precede header {line}:\n{text}"
3649                    );
3650                }
3651            }
3652        }
3653
3654        // The same views alone keep the pre-header layout exactly.
3655        for view in [ViewSpec::Tree, ViewSpec::Types, ViewSpec::Summary] {
3656            let lone = render(&fixture(&[view]), Format::Text, false);
3657            assert!(
3658                !lone.lines().any(is_view_header_line),
3659                "{view:?} alone must not be labelled:\n{lone}"
3660            );
3661        }
3662    }
3663
3664    #[test]
3665    fn view_headers_are_colorized_only_when_color_is_on() {
3666        let views = [ViewSpec::Tree, ViewSpec::Summary];
3667        let plain = render(&fixture(&views), Format::Text, false);
3668        assert!(plain.starts_with("TREE\n"), "{plain}");
3669        assert!(!plain.contains('\u{1b}'), "uncolored text carries no escapes: {plain:?}");
3670
3671        let colored = render(&fixture(&views), Format::Text, true);
3672        assert!(colored.contains(&paint("TREE", STYLE_VIEW_HEADER, true)), "{colored:?}");
3673        assert!(colored.contains(&paint("SUMMARY", STYLE_VIEW_HEADER, true)), "{colored:?}");
3674    }
3675
3676    #[test]
3677    fn no_machine_format_gains_a_text_header() {
3678        // Machine formats already name their view in a field. Text is a presentation
3679        // layer over the same report and must not leak into the versioned schemas.
3680        let views = [ViewSpec::Tree, ViewSpec::Types, ViewSpec::Files, ViewSpec::Summary];
3681        for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3682            let rendered = render(&fixture(&views), format, false);
3683            for header in ["TREE", "TYPES", "FILES", "SUMMARY"] {
3684                assert!(!rendered.contains(header), "{format:?} leaked {header}:\n{rendered}");
3685            }
3686        }
3687    }
3688
3689    #[test]
3690    fn every_view_has_a_header_that_matches_its_wire_label() {
3691        // The two spellings are written out separately so a schema change and a
3692        // presentation change stay independent; this is what keeps them from drifting
3693        // apart by accident while they are meant to agree.
3694        for view in [
3695            ViewSpec::Tree,
3696            ViewSpec::Extensions,
3697            ViewSpec::Types,
3698            ViewSpec::Families,
3699            ViewSpec::Languages,
3700            ViewSpec::Documents,
3701            ViewSpec::Files,
3702            ViewSpec::Summary,
3703        ] {
3704            let header = view_header(view);
3705            assert_eq!(header, view.label().to_uppercase(), "{view:?}");
3706            assert!(
3707                !header.is_empty() && header.chars().all(|c| c.is_ascii_uppercase()),
3708                "{view:?}"
3709            );
3710        }
3711    }
3712
3713    #[test]
3714    fn yaml_quotes_only_what_would_be_ambiguous() {
3715        assert_eq!(yaml_scalar("cold_scan"), "cold_scan");
3716        assert_eq!(yaml_scalar("src/main.rs"), "src/main.rs");
3717        // Bare words YAML would read as another type have to be quoted.
3718        assert_eq!(yaml_scalar("true"), "\"true\"");
3719        assert_eq!(yaml_scalar("null"), "\"null\"");
3720        assert_eq!(yaml_scalar("12345"), "\"12345\"");
3721        assert_eq!(yaml_scalar(""), "\"\"");
3722        assert_eq!(yaml_scalar("has space"), "\"has space\"");
3723        assert_eq!(yaml_scalar("-leading-dash"), "\"-leading-dash\"");
3724    }
3725
3726    #[test]
3727    fn json_strings_escape_control_characters_and_quotes() {
3728        assert_eq!(quote("a\"b"), "\"a\\\"b\"");
3729        assert_eq!(quote("a\\b"), "\"a\\\\b\"");
3730        assert_eq!(quote("a\nb"), "\"a\\nb\"");
3731        assert_eq!(quote("a\u{1}b"), "\"a\\u0001b\"");
3732    }
3733
3734    #[test]
3735    fn format_values_parse_and_reject_by_name() {
3736        assert_eq!(Format::parse("json"), Some(Format::Json));
3737        assert_eq!(Format::parse("  YAML "), Some(Format::Yaml));
3738        assert_eq!(Format::parse("xml"), None);
3739        assert_eq!(Format::ALL.len(), 7);
3740    }
3741
3742    #[test]
3743    fn human_bytes_reads_at_scale() {
3744        assert_eq!(human_bytes(0), "0 B");
3745        assert_eq!(human_bytes(512), "512 B");
3746        assert_eq!(human_bytes(1024), "1.0 KiB");
3747        assert_eq!(human_bytes(1024 * 1024 * 20), "20 MiB");
3748    }
3749
3750    #[test]
3751    fn bars_are_fixed_at_ten_cells_and_saturate() {
3752        assert_eq!(bar(0.0, false), "░░░░░░░░░░");
3753        assert_eq!(bar(0.5, false), "█████░░░░░");
3754        assert_eq!(bar(2.0, false), "██████████");
3755        assert!((ratio(5, 0) - 0.0).abs() < f64::EPSILON);
3756    }
3757
3758    const DEEP_RENDER_CHILD_ENV: &str = "FDU_DEEP_RENDER_CHILD";
3759    const DEEP_RENDER_DEPTH: usize = 1_024;
3760    const DEEP_RENDER_STACK_BYTES: usize = 64 * 1_024;
3761
3762    // ---- renderer tests that lived in the command line -------------------------------
3763    //
3764    // They test expansion and the three renderers, not argument handling, and they build
3765    // an index by hand -- which is why moving the CLI into its own crate surfaced them:
3766    // the fixture helpers they need are `pub(crate)` here and unreachable from there.
3767
3768    #[test]
3769    fn deep_rendering_is_stack_safe() {
3770        if std::env::var_os(DEEP_RENDER_CHILD_ENV).is_some() {
3771            run_deep_render_child();
3772            return;
3773        }
3774
3775        let output = Command::new(std::env::current_exe().expect("current test executable"))
3776            .args(["--exact", DEEP_RENDER_TEST_PATH, "--nocapture"])
3777            .env(DEEP_RENDER_CHILD_ENV, "1")
3778            .output()
3779            .expect("run deep-render child");
3780
3781        let stdout = String::from_utf8_lossy(&output.stdout);
3782        assert!(
3783            output.status.success(),
3784            "deep renderer failed in child process\nstdout:\n{stdout}\nstderr:\n{}",
3785            String::from_utf8_lossy(&output.stderr)
3786        );
3787
3788        // The exit code alone cannot tell "the deep render survived" from "the filter
3789        // matched nothing": libtest runs zero tests and exits 0 for a name that does not
3790        // exist, so a moved test would keep reporting success having stopped running --
3791        // which is what happened when this test moved out of `cli::tests` (fdu-rdom).
3792        assert!(
3793            stdout.contains("1 passed"),
3794            "the child must actually run the deep render, not filter it away\nstdout:\n{stdout}"
3795        );
3796    }
3797
3798    /// The child re-invocation filters on this, so it has to track the module the test
3799    /// lives in. Named once, beside the test, rather than spelled in the argument list
3800    /// where a move leaves it silently stale.
3801    const DEEP_RENDER_TEST_PATH: &str = "report_format::tests::deep_rendering_is_stack_safe";
3802
3803    fn run_deep_render_child() {
3804        // A deep tree must render, not abort: expansion and all three renderers use
3805        // explicit stacks, and this proves it on a 64 KiB stack where recursion would die.
3806        let mut index = crate::Index::new("/fixture");
3807        let mut path = PathBuf::new();
3808        for depth in 0..DEEP_RENDER_DEPTH {
3809            path.push("d");
3810            index.apply_ok(&crate::Observation::new(vec![crate::Op::Upsert {
3811                path: path.clone(),
3812                kind: EntryKind::Dir,
3813                attrs: crate::Attrs {
3814                    mtime_ns: i64::try_from(depth).expect("fixture depth fits i64"),
3815                    ..Default::default()
3816                },
3817            }]));
3818        }
3819        index.set_initial_freshness(false);
3820
3821        std::thread::Builder::new()
3822            .name("deep-render".to_string())
3823            .stack_size(DEEP_RENDER_STACK_BYTES)
3824            .spawn(move || {
3825                let query = Query {
3826                    selection: Selection { depth: Some(Bound::All), ..Selection::default() },
3827                    views: vec![ViewSpec::Tree],
3828                    ..Query::default()
3829                };
3830                let provenance = Provenance {
3831                    scan_started_at: None,
3832                    generated_at: SystemTime::UNIX_EPOCH,
3833                    source: ReportSource::ColdScan,
3834                    complete: true,
3835                    errors: Vec::new(),
3836                };
3837                let report = report(
3838                    &index,
3839                    &crate::test_support::read_of(&index, query.clone()),
3840                    &provenance,
3841                )
3842                .expect("report");
3843                for format in [Format::Text, Format::Json, Format::Jsonl, Format::Yaml] {
3844                    let rendered = render(&report, format, false);
3845                    assert!(!rendered.is_empty(), "{format:?} rendered nothing for a deep tree");
3846                    if format != Format::Text {
3847                        let mut streamed = Vec::new();
3848                        write(&report, format, false, &mut streamed).expect("stream deep report");
3849                        assert_eq!(streamed, rendered.as_bytes(), "{format:?} bytes differ");
3850                    }
3851                }
3852            })
3853            .expect("spawn deep-render thread")
3854            .join()
3855            .expect("deep-render thread");
3856    }
3857
3858    /// Two names that differ only in bytes `to_string_lossy` cannot represent must stay
3859    /// distinguishable in machine output.
3860    ///
3861    /// This coverage was lost when the CLI moved to the five axes: `raw_identity_json`
3862    /// survived the rewrite, its tests did not, and the merge from PR #6 is what surfaced
3863    /// the gap. Retargeted here to the report path rather than restored to the old
3864    /// `write_json`, because the guarantee belongs to the format, not to the flag that
3865    /// used to select it.
3866    fn assert_json_preserves_raw_identity(
3867        root: PathBuf,
3868        first: &OsStr,
3869        second: &OsStr,
3870        encoding: &str,
3871        root_hex: &str,
3872        first_hex: &str,
3873        second_hex: &str,
3874    ) {
3875        // The premise: lossy rendering collapses these two into the same string, so a
3876        // consumer with only `name` cannot tell them apart.
3877        assert_eq!(first.to_string_lossy(), second.to_string_lossy());
3878
3879        let mut index = crate::Index::new(root);
3880        index.apply_ok(&crate::Observation::new(vec![
3881            crate::Op::Upsert {
3882                path: PathBuf::from(first),
3883                kind: EntryKind::File,
3884                attrs: crate::Attrs { size: 1, allocated: 1, ..Default::default() },
3885            },
3886            crate::Op::Upsert {
3887                path: PathBuf::from(second),
3888                kind: EntryKind::File,
3889                attrs: crate::Attrs { size: 1, allocated: 1, ..Default::default() },
3890            },
3891        ]));
3892        index.set_initial_freshness(false);
3893
3894        // Built directly rather than through the command line's argument struct: what is
3895        // under test is that the renderer preserves a non-UTF-8 name's raw identity, and
3896        // routing that through argument parsing tied a renderer test to a front end.
3897        let query = crate::query::Query {
3898            views: vec![ViewSpec::Files],
3899            selection: Selection {
3900                depth: Some(crate::query::Bound::All),
3901                limit: Some(crate::query::Bound::All),
3902                ..Selection::default()
3903            },
3904            ..crate::query::Query::default()
3905        };
3906        let provenance = Provenance {
3907            scan_started_at: None,
3908            generated_at: std::time::UNIX_EPOCH,
3909            source: ReportSource::ColdScan,
3910            complete: true,
3911            errors: Vec::new(),
3912        };
3913        let files_report =
3914            report(&index, &crate::test_support::read_of(&index, query.clone()), &provenance)
3915                .expect("report");
3916        let rendered = render(&files_report, Format::Json, false);
3917        let mut checked = SchemaCheck::report(JsonSink::pretty(), true, true);
3918        emit_report(&mut checked, &files_report, true);
3919        assert_eq!(checked.finish(), rendered);
3920
3921        let lossy = first.to_string_lossy();
3922        assert_eq!(
3923            rendered.matches(&format!("\"{lossy}\"")).count(),
3924            2,
3925            "both names render the same lossy text: {rendered}"
3926        );
3927        assert!(
3928            rendered.contains(&format!(
3929                "\"root_raw\": {{\"encoding\": \"{encoding}\", \"hex\": \"{root_hex}\"}}"
3930            )),
3931            "{rendered}"
3932        );
3933
3934        // Pinned as the whole row rather than as a substring of it. A loose `contains`
3935        // check on the `path_raw` object alone passed while the row around it was
3936        // malformed -- the field was emitted with a duplicated separator and a newline
3937        // inside a one-line object, so the document did not parse at all. Asserting the
3938        // exact row is what makes the surrounding punctuation part of the contract.
3939        for hex in [first_hex, second_hex] {
3940            let row = format!(
3941                "{{\"path\": \"{lossy}\", \"path_raw\": {{\"encoding\": \"{encoding}\", \"hex\": \"{hex}\"}}, \
3942                 \"kind\": \"file\", \"bytes\": 1, \"allocated\": 1, \"mtime_ns\": 0, \
3943                 \"files\": null, \"dirs\": null, \"complete\": null, \"age_ns\": 0, \"ignored\": false}}"
3944            );
3945            assert!(
3946                compact_json(&rendered).contains(&compact_json(&row)),
3947                "a name that is not valid Unicode must carry its raw bytes in a well-formed \
3948                 row.\nexpected: {row}\nrendered: {rendered}"
3949            );
3950        }
3951
3952        // Cheap structural guard against the same class of mistake anywhere else in the
3953        // document: an empty element is the signature of a separator emitted twice.
3954        assert!(
3955            !rendered.contains(", ,") && !rendered.contains(",,"),
3956            "duplicated separator in machine output: {rendered}"
3957        );
3958
3959        // The tree writer names entries too, and carried the identical defect. Pinning
3960        // only the files view would have left half the fix untested. A tree lists
3961        // directories, so the case has to be a directory whose own name is not valid
3962        // Unicode rather than the files above.
3963        let mut dirs = crate::Index::new(PathBuf::from("/tree-fixture"));
3964        dirs.apply_ok(&crate::Observation::new(vec![
3965            crate::Op::Upsert {
3966                path: PathBuf::from(first),
3967                kind: EntryKind::Dir,
3968                attrs: crate::Attrs { size: 0, allocated: 0, ..Default::default() },
3969            },
3970            crate::Op::Upsert {
3971                path: PathBuf::from(first).join("inside.txt"),
3972                kind: EntryKind::File,
3973                attrs: crate::Attrs { size: 1, allocated: 1, ..Default::default() },
3974            },
3975        ]));
3976        dirs.set_initial_freshness(false);
3977        let tree_query = crate::query::Query {
3978            views: vec![ViewSpec::Tree],
3979            selection: Selection {
3980                depth: Some(crate::query::Bound::All),
3981                limit: Some(crate::query::Bound::All),
3982                ..Selection::default()
3983            },
3984            ..crate::query::Query::default()
3985        };
3986        let tree =
3987            report(&dirs, &crate::test_support::read_of(&dirs, tree_query.clone()), &provenance)
3988                .expect("report");
3989        let tree_rendered = render(&tree, Format::Json, false);
3990        assert!(
3991            compact_json(&tree_rendered).contains(&compact_json(&format!(
3992                ", \"path_raw\": {{\"encoding\": \"{encoding}\", \"hex\": \"{first_hex}\"}}, \"kind\":"
3993            ))),
3994            "the tree view must carry raw identity in a well-formed node: {tree_rendered}"
3995        );
3996        assert!(
3997            !tree_rendered.contains(", ,") && !tree_rendered.contains(",,"),
3998            "duplicated separator in tree output: {tree_rendered}"
3999        );
4000    }
4001
4002    #[cfg(unix)]
4003    #[test]
4004    fn json_preserves_distinct_non_unicode_unix_names() {
4005        use std::ffi::OsString;
4006        use std::os::unix::ffi::OsStringExt;
4007
4008        assert_json_preserves_raw_identity(
4009            PathBuf::from(OsString::from_vec(vec![b'/', 0x80])),
4010            &OsString::from_vec(vec![b'n', 0x80]),
4011            &OsString::from_vec(vec![b'n', 0x81]),
4012            "unix-bytes",
4013            "2f80",
4014            "6e80",
4015            "6e81",
4016        );
4017    }
4018
4019    #[cfg(windows)]
4020    #[test]
4021    fn json_preserves_distinct_non_unicode_windows_names() {
4022        use std::ffi::OsString;
4023        use std::os::windows::ffi::OsStringExt;
4024
4025        assert_json_preserves_raw_identity(
4026            PathBuf::from(OsString::from_wide(&[u16::from(b'R'), u16::from(b':'), 0xd800])),
4027            &OsString::from_wide(&[u16::from(b'n'), 0xd800]),
4028            &OsString::from_wide(&[u16::from(b'n'), 0xd801]),
4029            "windows-wtf16le",
4030            "52003a0000d8",
4031            "6e0000d8",
4032            "6e0001d8",
4033        );
4034    }
4035}