use std::fmt::Write as _;
use std::io;
use std::path::Path;
use anstyle::{AnsiColor, Style as AnsiStyle};
use crate::classify::human_language_name;
use crate::content::{CoverageReason, METRICS};
use crate::control::ControlCoverage;
use crate::emit::{Event, IoFmt, JsonSink, Scalar, Shape, Sink, YamlSink};
use crate::engine_contract::{Coverage, EntryKind, Freshness, IssueKind, Source};
use crate::query::{
FileRow, IgnoredEntries, IgnoredTally, MetricGroup, MetricRow, MetricSummary, Report,
ReportSource, Section, ShareMetric, SizeMetric, SummaryRow, TierState, TreeNode, TypeRow,
ViewSpec, format_rfc3339, format_rfc3339_nanos, pages,
};
const STYLE_VIEW_HEADER: AnsiStyle = AnsiColor::Cyan.on_default().bold();
const STYLE_DIRECTORY: AnsiStyle = AnsiColor::Cyan.on_default();
const STYLE_BAR: AnsiStyle = AnsiColor::Green.on_default();
const STYLE_TYPE: AnsiStyle = AnsiColor::Green.on_default();
const STYLE_TELEMETRY: AnsiStyle = AnsiColor::BrightBlack.on_default();
const TEXT_METRIC_LABEL_WIDTH: usize = 18;
const TEXT_TYPE_LABEL_WIDTH: usize = 12;
pub const REPORT_SCHEMA: &str = "fdu.report/7";
pub const CONTENT_REPORT_SCHEMA: &str = REPORT_SCHEMA;
pub const CACHE_SCHEMA: &str = "fdu.cache/2";
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum Format {
#[default]
Text,
Tree,
Paths,
Long,
Json,
Jsonl,
Yaml,
}
pub const fn document_start(format: Format) -> &'static str {
match format {
Format::Yaml => "---\n",
Format::Text
| Format::Tree
| Format::Paths
| Format::Long
| Format::Json
| Format::Jsonl => "",
}
}
impl Format {
pub const fn label(self) -> &'static str {
match self {
Self::Text => "text",
Self::Tree => "tree",
Self::Paths => "paths",
Self::Long => "long",
Self::Json => "json",
Self::Jsonl => "jsonl",
Self::Yaml => "yaml",
}
}
pub const fn is_machine(self) -> bool {
matches!(self, Self::Json | Self::Jsonl | Self::Yaml)
}
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"text" => Some(Self::Text),
"tree" => Some(Self::Tree),
"paths" => Some(Self::Paths),
"long" => Some(Self::Long),
"json" => Some(Self::Json),
"jsonl" => Some(Self::Jsonl),
"yaml" => Some(Self::Yaml),
_ => None,
}
}
pub const ALL: &'static [&'static str] =
&["text", "tree", "paths", "long", "json", "jsonl", "yaml"];
}
pub fn render(report: &Report, format: Format, color: bool) -> crate::Result<String> {
let format = checked_format(report, format)?;
Ok(match format {
Format::Text | Format::Tree => render_text(report, color),
Format::Paths | Format::Long => render_flat(report, format),
Format::Json => render_report_machine(report, true, JsonSink::pretty()),
Format::Jsonl => render_report_jsonl(report),
Format::Yaml => render_report_machine(report, true, YamlSink::new()),
})
}
fn flat_path(path: &Path) -> String {
path.to_string_lossy()
.chars()
.flat_map(|c| if c.is_control() { c.escape_default().collect::<Vec<_>>() } else { vec![c] })
.collect()
}
fn human_age(age: Option<i128>) -> String {
let Some(age) = age else { return "unknown".to_string() };
let seconds = age.unsigned_abs() / 1_000_000_000;
let (amount, unit) = if seconds >= 86400 {
(seconds / 86400, "d")
} else if seconds >= 3600 {
(seconds / 3600, "h")
} else if seconds >= 60 {
(seconds / 60, "m")
} else {
(seconds, "s")
};
format!("{}{amount}{unit}", if age < 0 { "-" } else { "" })
}
pub fn flat_diagnostics(report: &Report) -> Vec<String> {
let mut notes = report.notes.clone();
if report.provenance.source == ReportSource::CacheOnly {
notes.push("cache-only result: retained contents have not been revalidated".into());
}
if !report.status.complete || report.provenance.freshness != Freshness::Fresh {
notes.push(format!(
"result freshness: {}; complete: {}",
freshness_label(report.provenance.freshness),
report.status.complete
));
}
if let Some(depth) = report.scope.max_depth {
notes
.push(format!("scan scope limited to depth {depth}; subtree metrics cover this scope"));
}
for section in &report.sections {
let bound = bound_note(section);
if !bound.is_empty() {
notes.push(bound.trim().to_string());
}
}
notes
}
fn render_flat(report: &Report, format: Format) -> String {
let mut out = String::new();
for section in &report.sections {
if let Section::Files { rows, .. } = section {
for row in rows {
if format == Format::Long {
let _ = writeln!(
out,
"{:>10} {:>8} {}",
human_bytes(pick(report.size, row.bytes, row.allocated)),
human_age(row.age_ns),
flat_path(&row.path)
);
} else {
let _ = writeln!(out, "{}", flat_path(&row.path));
}
}
}
}
out
}
fn checked_format(report: &Report, format: Format) -> crate::Result<Format> {
let format = if format == Format::Text { report.format } else { format };
let valid = match format {
Format::Paths | Format::Long => {
report.sections.len() == 1 && matches!(report.sections[0], Section::Files { .. })
}
Format::Tree => {
report.sections.len() == 1 && matches!(report.sections[0], Section::Tree { .. })
}
Format::Text | Format::Json | Format::Jsonl | Format::Yaml => true,
};
if !valid {
return Err(crate::Error::InvalidRequest(crate::query::Rejection::new(format.label(),
"incompatible with this report projection; request the desired format when building the query (a folded tree cannot become a complete flat list)").on("format")));
}
Ok(format)
}
pub fn write(
report: &Report,
format: Format,
color: bool,
out: &mut dyn io::Write,
) -> io::Result<()> {
let format = checked_format(report, format)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
match format {
Format::Text | Format::Tree => out.write_all(render_text(report, color).as_bytes()),
Format::Paths | Format::Long => out.write_all(render_flat(report, format).as_bytes()),
Format::Json => write_report_machine(report, true, JsonSink::pretty_to(out)),
Format::Jsonl => write_report_jsonl(report, out),
Format::Yaml => write_report_machine(report, true, YamlSink::to(out)),
}
}
fn render_report_machine(
report: &Report,
with_sections: bool,
mut sink: impl Sink<Output = String>,
) -> String {
emit_report(&mut sink, report, with_sections);
sink.finish()
}
fn write_report_machine<'a>(
report: &Report,
with_sections: bool,
mut sink: impl Sink<Output = IoFmt<'a>>,
) -> io::Result<()> {
emit_report(&mut sink, report, with_sections);
sink.finish().finish()
}
fn render_report_jsonl(report: &Report) -> String {
let mut sink = JsonSink::line();
emit_report(&mut sink, report, false);
let mut out = sink.finish();
out.push('\n');
for section in &report.sections {
let mut sink = JsonSink::line();
emit_section(&mut sink, section);
out.push_str(&sink.finish());
out.push('\n');
}
out
}
fn write_report_jsonl(report: &Report, out: &mut dyn io::Write) -> io::Result<()> {
let mut sink = JsonSink::line_to(out);
emit_report(&mut sink, report, false);
sink.finish().finish()?;
out.write_all(b"\n")?;
for section in &report.sections {
let mut sink = JsonSink::line_to(out);
emit_section(&mut sink, section);
sink.finish().finish()?;
out.write_all(b"\n")?;
}
Ok(())
}
fn emit_field<S: Sink>(sink: &mut S, field: Field, condition: bool, emit: impl FnOnce(&mut S)) {
let present = match field.presence {
Presence::Always | Presence::Nullable => true,
Presence::WhenAnalyzer(_) => {
panic!("an analyzer-owned field must use emit_analyzer_field")
}
Presence::WhenLossy | Presence::WhenSet => condition,
};
if present {
sink.event(Event::Key(field.name));
emit(sink);
}
}
fn emit_analyzer_field<S: Sink>(
sink: &mut S,
requested: crate::content::AnalysisSet,
field: Field,
emit: impl FnOnce(&mut S),
) {
let Presence::WhenAnalyzer(owner) = field.presence else {
panic!("an analyzer field must declare its owning unit");
};
if requested.contains(owner) {
sink.event(Event::Key(field.name));
emit(sink);
}
}
fn emit_scalar(sink: &mut impl Sink, value: Scalar<'_>) {
sink.event(Event::Scalar(value));
}
fn emit_report(sink: &mut impl Sink, report: &Report, with_sections: bool) {
sink.event(Event::BeginMap(Shape::Block));
emit_field(sink, REPORT_FIELDS.schema, true, |sink| {
emit_scalar(sink, Scalar::Str(REPORT_SCHEMA));
});
let generator = generator();
emit_field(sink, REPORT_FIELDS.generator, true, |sink| {
emit_scalar(sink, Scalar::Str(&generator));
});
let root = report.root.to_string_lossy();
emit_field(sink, REPORT_FIELDS.root, true, |sink| {
emit_scalar(sink, Scalar::Str(&root));
});
emit_raw_identity(sink, REPORT_FIELDS.root_raw.name, &report.root);
emit_field(sink, REPORT_FIELDS.age_reference_ns, true, |sink| match report.age_reference_ns {
Some(value) => emit_scalar(sink, Scalar::I64(value)),
None => emit_scalar(sink, Scalar::Null),
});
emit_field(sink, REPORT_FIELDS.request, true, |sink| emit_request(sink, report));
emit_field(sink, REPORT_FIELDS.status, true, |sink| emit_status(sink, report));
emit_field(sink, REPORT_FIELDS.provenance, true, |sink| emit_provenance(sink, report));
emit_field(sink, REPORT_FIELDS.ignore_rules, true, |sink| {
emit_ignore_rules(sink, &report.ignore_rules);
});
emit_field(sink, REPORT_FIELDS.analysis, true, |sink| {
emit_analysis(sink, report.analysis.as_ref());
});
emit_field(sink, REPORT_FIELDS.reports, with_sections, |sink| {
sink.event(Event::BeginSeq(Shape::Block));
for section in &report.sections {
emit_section(sink, section);
}
sink.event(Event::EndSeq);
});
sink.event(Event::EndMap);
}
fn emit_request(sink: &mut impl Sink, report: &Report) {
sink.event(Event::BeginMap(Shape::Block));
emit_field(sink, Field::always("scope"), true, |sink| {
sink.event(Event::BeginMap(Shape::Block));
emit_field(sink, Field::nullable("max_depth"), true, |sink| {
emit_optional_usize(sink, report.scope.max_depth);
});
emit_bool_field(sink, "follow_symlinks", report.scope.follow_symlinks);
emit_bool_field(sink, "one_filesystem", report.scope.one_filesystem);
emit_bool_field(sink, "exclude_special", report.scope.exclude_special);
emit_bool_field(sink, "read_controls", report.scope.observes_controls());
sink.event(Event::EndMap);
});
emit_field(sink, Field::always("analyze"), true, |sink| {
sink.event(Event::BeginSeq(Shape::Inline));
for label in analysis_set_labels(report.requested_analysis) {
emit_scalar(sink, Scalar::Str(label));
}
sink.event(Event::EndSeq);
});
emit_str_field(sink, "size", report.size.label());
emit_field(sink, Field::always("views"), true, |sink| {
sink.event(Event::BeginSeq(Shape::Inline));
for view in &report.requested_views {
emit_scalar(sink, Scalar::Str(view.label()));
}
sink.event(Event::EndSeq);
});
emit_field(sink, Field::always("omitted_views"), true, |sink| {
sink.event(Event::BeginSeq(Shape::Inline));
for view in &report.omitted_views {
emit_scalar(sink, Scalar::Str(view.label()));
}
sink.event(Event::EndSeq);
});
sink.event(Event::EndMap);
}
fn emit_status(sink: &mut impl Sink, report: &Report) {
sink.event(Event::BeginMap(Shape::Block));
emit_bool_field(sink, "complete", report.status.complete);
emit_field(sink, Field::always("coverage"), true, |sink| {
sink.event(Event::BeginMap(Shape::Inline));
match report.status.coverage {
Coverage::Complete => emit_str_field(sink, "kind", "complete"),
Coverage::Partial(reason) => {
emit_str_field(sink, "kind", "partial");
emit_str_field(sink, "reason", structural_coverage_label(reason));
}
}
sink.event(Event::EndMap);
});
emit_field(sink, Field::always("errors"), true, |sink| {
sink.event(Event::BeginSeq(Shape::Block));
for error in &report.status.errors {
sink.event(Event::BeginMap(Shape::Block));
emit_field(sink, Field::when_set("path"), error.path.is_some(), |sink| {
let path = error.path.as_ref().expect("present error path");
let display = path.to_string_lossy();
emit_scalar(sink, Scalar::Str(&display));
});
if let Some(path) = &error.path {
emit_raw_identity(sink, "path_raw", path);
}
emit_str_field(sink, "kind", issue_kind_label(error.kind));
emit_str_field(sink, "message", &error.message);
emit_field(sink, Field::when_set("os_error"), error.os_error.is_some(), |sink| {
emit_scalar(sink, Scalar::I64(i64::from(error.os_error.expect("present errno"))));
});
sink.event(Event::EndMap);
}
sink.event(Event::EndSeq);
});
emit_u64_field(sink, "errors_omitted", report.status.errors_omitted);
sink.event(Event::EndMap);
}
fn emit_provenance(sink: &mut impl Sink, report: &Report) {
sink.event(Event::BeginMap(Shape::Block));
emit_str_field(sink, "source", source_label(report.provenance.source));
emit_str_field(sink, "freshness", freshness_label(report.provenance.freshness));
emit_field(sink, Field::nullable("scan_started_at"), true, |sink| {
if let Some(at) = report.provenance.scan_started_at {
let value = format_rfc3339(at);
emit_scalar(sink, Scalar::Str(&value));
} else {
emit_scalar(sink, Scalar::Null);
}
});
let generated_at = format_rfc3339(report.provenance.generated_at);
emit_str_field(sink, "generated_at", &generated_at);
emit_field(sink, Field::always("tiers"), true, |sink| {
sink.event(Event::BeginMap(Shape::Block));
emit_field(sink, Field::always("entries"), true, |sink| {
emit_tier_state(sink, report.provenance.tiers.entries);
});
emit_field(sink, Field::nullable("content"), true, |sink| {
if let Some(content) = report.provenance.tiers.content {
emit_tier_state(sink, content);
} else {
emit_scalar(sink, Scalar::Null);
}
});
sink.event(Event::EndMap);
});
sink.event(Event::EndMap);
}
fn emit_tier_state(sink: &mut impl Sink, tier: TierState) {
sink.event(Event::BeginMap(Shape::Inline));
emit_str_field(sink, "source", tier_source_label(tier.source));
emit_str_field(sink, "freshness", freshness_label(tier.freshness));
emit_field(sink, Field::nullable("observed_at_ns"), true, |sink| match tier.observed_at_ns {
Some(value) => emit_scalar(sink, Scalar::I64(value)),
None => emit_scalar(sink, Scalar::Null),
});
sink.event(Event::EndMap);
}
fn emit_ignore_rules(sink: &mut impl Sink, rules: &ControlCoverage) {
let ControlCoverage::Observed(observed) = rules else {
emit_scalar(sink, Scalar::Null);
return;
};
sink.event(Event::BeginMap(Shape::Block));
emit_field(sink, Field::always("limits"), true, |sink| {
sink.event(Event::BeginMap(Shape::Inline));
emit_field(sink, Field::nullable("budget"), true, |sink| {
emit_optional_usize(sink, observed.limits.budget);
});
emit_field(sink, Field::nullable("line_limit"), true, |sink| {
emit_optional_usize(sink, observed.limits.line_limit);
});
sink.event(Event::EndMap);
});
emit_u64_field(sink, "applied", observed.applied);
emit_u64_field(sink, "refused", observed.refused);
emit_field(sink, Field::always("refusals"), true, |sink| {
sink.event(Event::BeginSeq(Shape::Block));
for refusal in &observed.refusals {
sink.event(Event::BeginMap(Shape::Block));
emit_path_fields(sink, &refusal.path);
emit_str_field(sink, "reason", refusal.reason.label());
sink.event(Event::EndMap);
}
sink.event(Event::EndSeq);
});
sink.event(Event::EndMap);
}
fn emit_analysis(sink: &mut impl Sink, analysis: Option<&crate::query::ContentReportMetadata>) {
let Some(analysis) = analysis else {
emit_scalar(sink, Scalar::Null);
return;
};
sink.event(Event::BeginMap(Shape::Block));
emit_field(sink, Field::always("analyze"), true, |sink| {
sink.event(Event::BeginSeq(Shape::Inline));
for label in analysis_set_labels(analysis.profile) {
emit_scalar(sink, Scalar::Str(label));
}
sink.event(Event::EndSeq);
});
emit_u64_field(sink, "type_rules_fingerprint", analysis.provenance.type_rules_fingerprint);
emit_u64_field(sink, "options_fingerprint", analysis.provenance.options_fingerprint.0);
emit_field(sink, Field::always("analyzers"), true, |sink| {
sink.event(Event::BeginSeq(Shape::Block));
for (id, version) in &analysis.provenance.analyzers {
sink.event(Event::BeginMap(Shape::Inline));
emit_str_field(sink, "id", id.0);
emit_u64_field(sink, "version", u64::from(version.0));
sink.event(Event::EndMap);
}
sink.event(Event::EndSeq);
});
sink.event(Event::EndMap);
}
fn emit_optional_usize(sink: &mut impl Sink, value: Option<usize>) {
match value {
Some(value) => emit_scalar(sink, Scalar::U64(value as u64)),
None => emit_scalar(sink, Scalar::Null),
}
}
fn emit_str_field(sink: &mut impl Sink, name: &'static str, value: &str) {
emit_field(sink, Field::always(name), true, |sink| {
emit_scalar(sink, Scalar::Str(value));
});
}
fn emit_u64_field(sink: &mut impl Sink, name: &'static str, value: u64) {
emit_field(sink, Field::always(name), true, |sink| {
emit_scalar(sink, Scalar::U64(value));
});
}
fn emit_bool_field(sink: &mut impl Sink, name: &'static str, value: bool) {
emit_field(sink, Field::always(name), true, |sink| {
emit_scalar(sink, Scalar::Bool(value));
});
}
fn emit_i64_field(sink: &mut impl Sink, name: &'static str, value: i64) {
emit_field(sink, Field::always(name), true, |sink| {
emit_scalar(sink, Scalar::I64(value));
});
}
fn emit_raw_identity(sink: &mut impl Sink, name: &'static str, path: &Path) {
let raw = raw_os_identity(path.as_os_str());
emit_field(sink, Field::when_lossy(name), raw.is_some(), |sink| {
let (encoding, hex) = raw.expect("lossy field predicate checked the raw identity");
sink.event(Event::BeginMap(Shape::Inline));
emit_str_field(sink, "encoding", encoding);
emit_str_field(sink, "hex", &hex);
sink.event(Event::EndMap);
});
}
fn emit_path_fields(sink: &mut impl Sink, path: &Path) {
let lossy = path.to_string_lossy();
emit_str_field(sink, "path", &lossy);
emit_raw_identity(sink, "path_raw", path);
}
fn emit_section(sink: &mut impl Sink, section: &Section) {
sink.event(Event::BeginMap(Shape::Block));
emit_str_field(sink, "view", section.view().label());
match section {
Section::Tree { root, .. } => {
emit_field(sink, Field::always("tree"), true, |sink| emit_tree(sink, root));
}
Section::Extensions { rows, total } => {
emit_bound_field(sink, rows.len(), *total);
emit_field(sink, Field::always("extensions"), true, |sink| {
sink.event(Event::BeginSeq(Shape::Block));
for row in rows {
sink.event(Event::BeginMap(Shape::Block));
emit_str_field(sink, "extension", &row.extension);
emit_u64_field(sink, "files", row.files);
emit_u64_field(sink, "bytes", row.bytes);
emit_u64_field(sink, "allocated", row.allocated);
emit_field(sink, Field::nullable("ignored"), true, |sink| {
emit_ignored(sink, row.ignored, false);
});
sink.event(Event::EndMap);
}
sink.event(Event::EndSeq);
});
}
Section::Metrics { summary, .. } => {
emit_field(sink, Field::always("metrics"), true, |sink| {
emit_metric_summary(sink, summary);
});
}
Section::Files { rows, total, .. } => {
emit_bound_field(sink, rows.len(), *total);
emit_field(sink, Field::always("files"), true, |sink| {
sink.event(Event::BeginSeq(Shape::Block));
for row in rows {
emit_file_row(sink, row);
}
sink.event(Event::EndSeq);
});
}
Section::Summary(row) => {
emit_field(sink, Field::always("summary"), true, |sink| {
emit_summary_row(sink, row);
});
}
}
sink.event(Event::EndMap);
}
fn emit_bound_field(sink: &mut impl Sink, shown: usize, total: usize) {
emit_field(sink, Field::nullable("bound"), true, |sink| {
if shown >= total {
emit_scalar(sink, Scalar::Null);
} else {
sink.event(Event::BeginMap(Shape::Inline));
emit_u64_field(sink, "shown", shown as u64);
emit_u64_field(sink, "total", total as u64);
sink.event(Event::EndMap);
}
});
}
fn emit_file_row(sink: &mut impl Sink, row: &FileRow) {
sink.event(Event::BeginMap(Shape::Block));
emit_path_fields(sink, &row.path);
emit_str_field(sink, "kind", kind_label(row.kind));
emit_u64_field(sink, "bytes", row.bytes);
emit_u64_field(sink, "allocated", row.allocated);
emit_i64_field(sink, "mtime_ns", row.mtime_ns);
for (name, value) in [("files", row.files), ("dirs", row.dirs)] {
emit_field(sink, Field::nullable(name), true, |sink| match value {
Some(value) => emit_scalar(sink, Scalar::U64(value)),
None => emit_scalar(sink, Scalar::Null),
});
}
emit_field(sink, Field::nullable("complete"), true, |sink| match row.complete {
Some(value) => emit_scalar(sink, Scalar::Bool(value)),
None => emit_scalar(sink, Scalar::Null),
});
emit_field(sink, Field::nullable("age_ns"), true, |sink| match row.age_ns {
Some(value) => emit_scalar(sink, Scalar::I128(value)),
None => emit_scalar(sink, Scalar::Null),
});
emit_field(sink, Field::nullable("ignored"), true, |sink| match row.ignored {
Some(value) => emit_scalar(sink, Scalar::Bool(value)),
None => emit_scalar(sink, Scalar::Null),
});
sink.event(Event::EndMap);
}
fn emit_summary_row(sink: &mut impl Sink, row: &SummaryRow) {
sink.event(Event::BeginMap(Shape::Block));
emit_u64_field(sink, "files", row.files);
emit_u64_field(sink, "dirs", row.dirs);
emit_u64_field(sink, "bytes", row.bytes);
emit_u64_field(sink, "allocated", row.allocated);
emit_field(sink, Field::nullable("ignored"), true, |sink| {
emit_ignored(sink, row.ignored, true);
});
emit_field(sink, Field::nullable("newest_mtime_ns"), true, |sink| match row.newest_mtime_ns {
Some(value) => emit_scalar(sink, Scalar::I64(value)),
None => emit_scalar(sink, Scalar::Null),
});
sink.event(Event::EndMap);
}
fn emit_ignored(sink: &mut impl Sink, ignored: Option<IgnoredTally>, with_dirs: bool) {
let Some(ignored) = ignored else {
emit_scalar(sink, Scalar::Null);
return;
};
sink.event(Event::BeginMap(Shape::Inline));
emit_u64_field(sink, "files", ignored.files);
if with_dirs {
emit_u64_field(sink, "dirs", ignored.dirs);
}
emit_u64_field(sink, "bytes", ignored.bytes);
emit_u64_field(sink, "allocated", ignored.allocated);
sink.event(Event::EndMap);
}
fn emit_metric_summary(sink: &mut impl Sink, summary: &MetricSummary) {
sink.event(Event::BeginMap(Shape::Block));
emit_str_field(sink, "group", metric_group_label(summary.group));
emit_str_field(sink, "share_metric", summary.share_metric.as_str());
emit_bound_field(sink, summary.rows.len(), summary.total_rows);
emit_field(sink, Field::always("total"), true, |sink| {
emit_metric_row(sink, &summary.total, summary.words_per_page);
});
emit_field(sink, Field::always("rows"), true, |sink| {
sink.event(Event::BeginSeq(Shape::Block));
for row in &summary.rows {
emit_metric_row(sink, row, summary.words_per_page);
}
sink.event(Event::EndSeq);
});
sink.event(Event::EndMap);
}
fn emit_metric_row(sink: &mut impl Sink, row: &MetricRow, words_per_page: u64) {
sink.event(Event::BeginMap(Shape::Block));
emit_str_field(sink, "id", &row.id);
emit_str_field(sink, "family", row.family.as_str());
emit_u64_field(sink, "files", row.files);
emit_u64_field(sink, "bytes", row.bytes);
emit_u64_field(sink, "allocated", row.allocated);
emit_field(sink, Field::always("share"), true, |sink| {
sink.event(Event::BeginMap(Shape::Inline));
emit_u64_field(sink, "numerator", row.share.numerator);
emit_u64_field(sink, "denominator", row.share.denominator);
sink.event(Event::EndMap);
});
emit_field(sink, Field::always("metrics"), true, |sink| {
emit_metric_values(sink, row);
});
emit_field(sink, Field::always("coverage"), true, |sink| {
sink.event(Event::BeginMap(Shape::Block));
emit_analyzer_field(
sink,
row.analysis,
Field::when_analyzer("lines", crate::content::AnalysisSet::LINES_ONLY),
|sink| emit_coverage_map(sink, &row.lines_coverage),
);
emit_analyzer_field(
sink,
row.analysis,
Field::when_analyzer("code", crate::content::AnalysisSet::CODE_ONLY),
|sink| emit_coverage_map(sink, row.code_coverage.as_ref().expect("code requested")),
);
emit_analyzer_field(
sink,
row.analysis,
Field::when_analyzer("words", crate::content::AnalysisSet::WORDS_ONLY),
|sink| emit_coverage_map(sink, row.words_coverage.as_ref().expect("words requested")),
);
sink.event(Event::EndMap);
});
emit_field(sink, Field::always("detection"), true, |sink| {
emit_detection(sink, row);
});
emit_analyzer_field(
sink,
row.analysis,
Field::when_analyzer("pages", crate::content::AnalysisSet::WORDS_ONLY),
|sink| {
let page = pages(row, words_per_page).expect("words request has page inputs");
sink.event(Event::BeginMap(Shape::Inline));
emit_u64_field(sink, "words", page.words);
emit_u64_field(sink, "words_per_page", page.words_per_page);
sink.event(Event::EndMap);
},
);
sink.event(Event::EndMap);
}
fn emit_metric_values(sink: &mut impl Sink, row: &MetricRow) {
sink.event(Event::BeginMap(Shape::Inline));
for metric in METRICS {
emit_analyzer_field(
sink,
row.analysis,
Field::when_analyzer(metric.name, metric.owner),
|sink| {
emit_scalar(
sink,
Scalar::U64(row.metric_value(metric).expect("metric owner requested")),
);
},
);
}
sink.event(Event::EndMap);
}
fn emit_coverage_map(
sink: &mut impl Sink,
coverage: &std::collections::BTreeMap<CoverageReason, u64>,
) {
sink.event(Event::BeginMap(Shape::Inline));
for (reason, count) in coverage {
emit_u64_field(sink, coverage_label(*reason), *count);
}
sink.event(Event::EndMap);
}
fn emit_detection(sink: &mut impl Sink, row: &MetricRow) {
sink.event(Event::BeginMap(Shape::Block));
emit_field(sink, Field::always("sources"), true, |sink| {
sink.event(Event::BeginMap(Shape::Inline));
for (source, count) in &row.detection_sources {
emit_u64_field(sink, source.as_str(), *count);
}
sink.event(Event::EndMap);
});
emit_field(sink, Field::always("confidence"), true, |sink| {
sink.event(Event::BeginMap(Shape::Inline));
for (level, count) in &row.detection_confidence {
emit_u64_field(sink, level.as_str(), *count);
}
sink.event(Event::EndMap);
});
emit_field(sink, Field::always("flags"), true, |sink| {
sink.event(Event::BeginMap(Shape::Inline));
emit_u64_field(sink, "generated", row.generated_files);
emit_u64_field(sink, "vendored", row.vendored_files);
emit_u64_field(sink, "documentation", row.documentation_files);
sink.event(Event::EndMap);
});
sink.event(Event::EndMap);
}
fn emit_tree(sink: &mut impl Sink, root: &TreeNode) {
enum Step<'a> {
Node(&'a TreeNode),
Children(std::slice::Iter<'a, TreeNode>),
EndMap,
EndSeq,
}
let mut stack = vec![Step::Node(root)];
while let Some(step) = stack.pop() {
match step {
Step::Node(node) => {
sink.event(Event::BeginMap(Shape::Block));
emit_str_field(sink, "name", &node.name);
emit_path_fields(sink, &node.path);
emit_str_field(sink, "kind", kind_label(node.kind));
emit_u64_field(sink, "bytes", node.bytes);
emit_u64_field(sink, "allocated", node.allocated);
emit_u64_field(sink, "files", node.files);
emit_u64_field(sink, "dirs", node.dirs);
emit_field(sink, Field::nullable("ignored"), true, |sink| {
emit_ignored(sink, node.ignored, true);
});
emit_field(sink, Field::nullable("newest_mtime_ns"), true, |sink| {
match node.newest_mtime_ns {
Some(value) => emit_scalar(sink, Scalar::I64(value)),
None => emit_scalar(sink, Scalar::Null),
}
});
emit_field(sink, Field::always("truncated"), true, |sink| {
emit_scalar(sink, Scalar::Bool(node.truncated));
});
sink.event(Event::Key("children"));
sink.event(Event::BeginSeq(Shape::Block));
stack.push(Step::EndMap);
stack.push(Step::EndSeq);
stack.push(Step::Children(node.children.iter()));
}
Step::Children(mut children) => {
if let Some(child) = children.next() {
stack.push(Step::Children(children));
stack.push(Step::Node(child));
}
}
Step::EndMap => sink.event(Event::EndMap),
Step::EndSeq => sink.event(Event::EndSeq),
}
}
}
struct ReportFields {
schema: Field,
generator: Field,
root: Field,
root_raw: Field,
request: Field,
status: Field,
provenance: Field,
ignore_rules: Field,
analysis: Field,
reports: Field,
age_reference_ns: Field,
}
const REPORT_FIELDS: ReportFields = ReportFields {
schema: Field::always("schema"),
generator: Field::always("generator"),
root: Field::always("root"),
root_raw: Field::when_lossy("root_raw"),
request: Field::always("request"),
status: Field::always("status"),
provenance: Field::always("provenance"),
ignore_rules: Field::always("ignore_rules"),
analysis: Field::nullable("analysis"),
reports: Field::when_set("reports"),
age_reference_ns: Field::nullable("age_reference_ns"),
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Presence {
Always,
Nullable,
WhenLossy,
WhenAnalyzer(crate::content::AnalysisSet),
WhenSet,
}
#[derive(Clone, Copy, Debug)]
struct Field {
name: &'static str,
presence: Presence,
}
impl Field {
const fn always(name: &'static str) -> Self {
Self { name, presence: Presence::Always }
}
const fn nullable(name: &'static str) -> Self {
Self { name, presence: Presence::Nullable }
}
const fn when_lossy(name: &'static str) -> Self {
Self { name, presence: Presence::WhenLossy }
}
const fn when_analyzer(name: &'static str, analysis: crate::content::AnalysisSet) -> Self {
Self { name, presence: Presence::WhenAnalyzer(analysis) }
}
const fn when_set(name: &'static str) -> Self {
Self { name, presence: Presence::WhenSet }
}
}
fn paint(text: &str, style: AnsiStyle, color: bool) -> String {
if color { format!("{style}{text}{style:#}") } else { text.to_string() }
}
fn render_text(report: &Report, color: bool) -> String {
let mut out = String::new();
let headed = report.sections.len() > 1;
for (index, section) in report.sections.iter().enumerate() {
if index > 0 {
out.push('\n');
}
let bound = bound_note(section);
if headed {
let _ = writeln!(
out,
"{}{}",
paint(view_header(section.view()), STYLE_VIEW_HEADER, color),
paint(&bound, STYLE_TELEMETRY, color),
);
} else if !bound.is_empty() {
let _ = writeln!(out, "{}", paint(bound.trim_start(), STYLE_TELEMETRY, color));
}
match section {
Section::Tree { root, .. } => {
render_text_tree(&mut out, root, report.size, report.ignored_entries, color);
}
Section::Extensions { rows, .. } => {
render_text_types(&mut out, rows, report.size, report.ignored_entries, color);
}
Section::Metrics { view, summary } => {
render_text_metrics(&mut out, *view, summary, report.size, color);
}
Section::Files { view, rows, .. } => match view {
ViewSpec::Largest => render_text_ranked_files(&mut out, rows, report.size, |row| {
human_bytes(pick(report.size, row.bytes, row.allocated))
}),
ViewSpec::Recent => render_text_ranked_files(&mut out, rows, report.size, |row| {
format_rfc3339_nanos(row.mtime_ns)
}),
_ => {
for row in rows {
let _ = writeln!(out, "{}", row.path.display());
}
}
},
Section::Summary(row) => {
render_text_summary(&mut out, row, report.size, report.ignored_entries);
}
}
}
for note in &report.notes {
let _ = writeln!(out, "{}", paint(note, STYLE_TELEMETRY, color));
}
out
}
const TEXT_SIZE_WIDTH: usize = 10;
const TEXT_SHARE_WIDTH: usize = 6;
fn label_cell(label: &str, width: usize, style: AnsiStyle, color: bool) -> String {
let padding = " ".repeat(width.saturating_sub(label.chars().count()));
format!("{}{padding}", paint(label, style, color))
}
fn label_width<'a>(labels: impl Iterator<Item = &'a str>, minimum: usize) -> usize {
minimum.max(labels.map(|label| label.chars().count()).max().unwrap_or_default())
}
fn render_text_metrics(
out: &mut String,
view: ViewSpec,
summary: &MetricSummary,
size: SizeMetric,
color: bool,
) {
if let Some(note) = share_metric_note(summary.share_metric) {
let _ = writeln!(out, "{}", paint(note, STYLE_TELEMETRY, color));
}
let width = if view == ViewSpec::Languages {
label_width(summary.rows.iter().map(|row| human_metric_label(view, &row.id)), 0)
.saturating_add(1)
} else {
label_width(
summary.rows.iter().map(|row| human_metric_label(view, &row.id)),
TEXT_METRIC_LABEL_WIDTH,
)
};
for row in &summary.rows {
let selected = pick(size, row.bytes, row.allocated);
let percentage = if row.share.denominator == 0 {
"—".to_string()
} else {
format!("{:.1}%", ratio(row.share.numerator, row.share.denominator) * 100.0)
};
let mut suffix = format!("{} {}", row.files, plural(row.files, "file", "files"));
if let Some(physical_lines) = row.metrics.physical_lines.filter(|lines| *lines > 0) {
let code_fully_analyzed = row.code_coverage.as_ref().is_some_and(|coverage| {
coverage.len() == 1 && coverage.get(&CoverageReason::Analyzed) == Some(&row.files)
});
if let (true, Some(code_lines), Some(comment_lines), Some(code_blank_lines)) = (
code_fully_analyzed,
row.metrics.code_lines,
row.metrics.comment_lines,
row.metrics.code_blank_lines,
) {
let _ = write!(
suffix,
", {physical_lines} lines ({code_lines} code, {comment_lines} comment, \
{code_blank_lines} blank)"
);
} else {
let _ = write!(
suffix,
", {} lines ({} nonblank, {} blank)",
physical_lines,
row.metrics.nonblank_lines.expect("lines requested"),
row.metrics.blank_lines.expect("lines requested")
);
}
}
if let Some(page) = pages(row, summary.words_per_page).filter(|page| page.words > 0) {
let page_tenths = page.words.saturating_mul(10) / page.words_per_page;
let _ = write!(
suffix,
", {} words ({}.{:01} pages)",
page.words,
page_tenths / 10,
page_tenths % 10
);
}
if row.generated_files > 0 {
let _ = write!(suffix, ", {} generated", row.generated_files);
}
if row.vendored_files > 0 {
let _ = write!(suffix, ", {} vendored", row.vendored_files);
}
if row.documentation_files > 0 {
let _ = write!(suffix, ", {} documentation", row.documentation_files);
}
let coverage = match view {
ViewSpec::Languages => row.code_coverage.as_ref().unwrap_or(&row.lines_coverage),
ViewSpec::Documents => row.words_coverage.as_ref().unwrap_or(&row.lines_coverage),
_ => &row.lines_coverage,
};
for (reason, count) in coverage {
if *reason != CoverageReason::Analyzed {
let _ = write!(suffix, ", {count} {}", human_coverage_label(*reason));
}
}
let _ = writeln!(
out,
"{:>TEXT_SIZE_WIDTH$} {:>TEXT_SHARE_WIDTH$} {} {suffix}",
human_bytes(selected),
percentage,
label_cell(human_metric_label(view, &row.id), width, STYLE_TYPE, color),
);
}
}
fn share_metric_note(metric: ShareMetric) -> Option<&'static str> {
match metric {
ShareMetric::CodeLines => Some("Percentage column: code lines"),
ShareMetric::DocumentWords => Some("Percentage column: document words"),
ShareMetric::RawWords => Some("Percentage column: raw words"),
ShareMetric::ApparentBytes | ShareMetric::AllocatedBytes => None,
}
}
fn human_metric_label(view: ViewSpec, id: &str) -> &str {
if view == ViewSpec::Languages { human_language_name(id) } else { id }
}
fn human_coverage_label(reason: CoverageReason) -> &'static str {
match reason {
CoverageReason::Analyzed => "analyzed",
CoverageReason::Binary => "binary",
CoverageReason::InvalidUtf8 => "invalid UTF-8",
CoverageReason::UnsupportedEncoding => "unsupported encoding",
CoverageReason::Unsupported => "unsupported",
CoverageReason::IoError => "I/O error",
CoverageReason::ChangedDuringRead => "changed during read",
}
}
fn ignored_suffix(
ignored: Option<IgnoredTally>,
size: SizeMetric,
selected: IgnoredEntries,
) -> String {
let shown = match selected {
IgnoredEntries::Include | IgnoredEntries::Exclude => {
ignored.filter(|share| share.files > 0)
}
IgnoredEntries::Only => None,
};
shown.map_or_else(String::new, |share| {
format!(" ({} ignored)", human_bytes(pick(size, share.bytes, share.allocated)))
})
}
fn render_text_tree(
out: &mut String,
root: &TreeNode,
size: SizeMetric,
selected: IgnoredEntries,
color: bool,
) {
enum Row<'a> {
Node(&'a TreeNode, usize),
Truncation(usize),
}
let grand = pick(size, root.bytes, root.allocated);
let mut stack = vec![Row::Node(root, 0)];
while let Some(row) = stack.pop() {
match row {
Row::Node(node, depth) => {
let bytes = pick(size, node.bytes, node.allocated);
let share = ratio(bytes, grand);
let indent = " ".repeat(depth);
let _ = writeln!(
out,
"{:>10} {} {:>4.0}% {indent}{} ({} {}){}",
human_bytes(bytes),
bar(share, color),
share * 100.0,
paint(&node.name, STYLE_DIRECTORY, color),
node.files,
plural(node.files, "file", "files"),
ignored_suffix(node.ignored, size, selected),
);
if node.truncated && !node.children.is_empty() {
stack.push(Row::Truncation(depth + 1));
}
for child in node.children.iter().rev() {
stack.push(Row::Node(child, depth + 1));
}
}
Row::Truncation(depth) => {
let indent = " ".repeat(depth);
let _ = writeln!(out, "{:>10} {:10} {:>5} {indent}…", "", "", "");
}
}
}
}
fn render_text_types(
out: &mut String,
rows: &[TypeRow],
size: SizeMetric,
selected: IgnoredEntries,
color: bool,
) {
let width = label_width(rows.iter().map(|row| row.extension.as_str()), TEXT_TYPE_LABEL_WIDTH);
for row in rows {
let _ = writeln!(
out,
"{:>TEXT_SIZE_WIDTH$} {} {} {}{}",
human_bytes(pick(size, row.bytes, row.allocated)),
label_cell(&row.extension, width, STYLE_TYPE, color),
row.files,
plural(row.files, "file", "files"),
ignored_suffix(row.ignored, size, selected),
);
}
}
fn bound_note(section: &Section) -> String {
let (shown, total) = match section {
Section::Files { rows, total, .. } => (rows.len(), *total),
Section::Extensions { rows, total } => (rows.len(), *total),
Section::Metrics { summary, .. } => (summary.rows.len(), summary.total_rows),
Section::Tree { .. } | Section::Summary(_) => (0, 0),
};
if shown >= total {
return String::new();
}
format!(
" ({} of {}; --limit all for every one)",
human_count(shown as u64),
human_count(total as u64)
)
}
fn render_text_ranked_files(
out: &mut String,
rows: &[FileRow],
_size: SizeMetric,
measure: impl Fn(&FileRow) -> String,
) {
let width = rows.iter().map(|row| measure(row).chars().count()).max().unwrap_or_default();
for row in rows {
let _ = writeln!(out, "{:>width$} {}", measure(row), row.path.display());
}
}
fn render_text_summary(
out: &mut String,
row: &SummaryRow,
size: SizeMetric,
selected: IgnoredEntries,
) {
let _ = writeln!(
out,
"{:>10} {} {}, {} {}{}",
human_bytes(pick(size, row.bytes, row.allocated)),
row.files,
plural(row.files, "file", "files"),
row.dirs,
plural(row.dirs, "directory", "directories"),
ignored_suffix(row.ignored, size, selected),
);
}
#[cfg(test)]
fn yaml_scalar(value: &str) -> String {
let mut out = String::new();
crate::emit::write_yaml_scalar(&mut out, value);
out
}
#[cfg(test)]
fn quote(text: &str) -> String {
let mut out = String::with_capacity(text.len() + 2);
crate::emit::write_json_string(&mut out, text);
out
}
fn generator() -> String {
format!("fdu {}", env!("CARGO_PKG_VERSION"))
}
fn view_header(view: ViewSpec) -> &'static str {
match view {
ViewSpec::List => "LIST",
ViewSpec::Tree => "TREE",
ViewSpec::Types => "TYPES",
ViewSpec::Extensions => "EXTENSIONS",
ViewSpec::Families => "FAMILIES",
ViewSpec::Languages => "LANGUAGES",
ViewSpec::Documents => "DOCUMENTS",
ViewSpec::Files => "FILES",
ViewSpec::Largest => "LARGEST",
ViewSpec::Recent => "RECENT",
ViewSpec::Summary => "SUMMARY",
}
}
fn metric_group_label(group: MetricGroup) -> &'static str {
match group {
MetricGroup::Type => "type",
MetricGroup::Family => "family",
}
}
fn coverage_label(reason: CoverageReason) -> &'static str {
match reason {
CoverageReason::Analyzed => "analyzed",
CoverageReason::Binary => "binary",
CoverageReason::InvalidUtf8 => "invalid_utf8",
CoverageReason::UnsupportedEncoding => "unsupported_encoding",
CoverageReason::Unsupported => "unsupported",
CoverageReason::IoError => "io_error",
CoverageReason::ChangedDuringRead => "changed_during_read",
}
}
fn analysis_set_labels(profile: crate::content::AnalysisSet) -> Vec<&'static str> {
profile.labels()
}
fn source_label(source: ReportSource) -> &'static str {
match source {
ReportSource::ColdScan => "cold_scan",
ReportSource::WarmRevalidate => "warm_revalidate",
ReportSource::CacheOnly => "cache_only",
}
}
fn tier_source_label(source: Source) -> &'static str {
match source {
Source::Scanned => "scanned",
Source::Revalidated => "revalidated",
Source::JournalScoped => "journal_scoped",
Source::Cached => "cached",
}
}
fn structural_coverage_label(reason: crate::engine_contract::CoverageReason) -> &'static str {
match reason {
crate::engine_contract::CoverageReason::Building => "building",
crate::engine_contract::CoverageReason::Budget => "budget",
crate::engine_contract::CoverageReason::Cancelled => "cancelled",
crate::engine_contract::CoverageReason::Inaccessible => "inaccessible",
crate::engine_contract::CoverageReason::Failed => "failed",
}
}
fn issue_kind_label(kind: IssueKind) -> &'static str {
match kind {
IssueKind::Permission => "permission",
IssueKind::Disappeared => "disappeared",
IssueKind::InvalidMetadata => "invalid_metadata",
IssueKind::ResourceBudget => "resource_budget",
IssueKind::ObservationGap => "observation_gap",
IssueKind::ProviderFailure => "provider_failure",
}
}
fn freshness_label(freshness: Freshness) -> &'static str {
match freshness {
Freshness::Fresh => "fresh",
Freshness::Reconciling => "reconciling",
Freshness::Stale => "stale",
Freshness::Partial => "partial",
}
}
fn kind_label(kind: EntryKind) -> &'static str {
match kind {
EntryKind::File => "file",
EntryKind::Dir => "dir",
EntryKind::Symlink => "symlink",
EntryKind::Other => "other",
}
}
fn pick(size: SizeMetric, apparent: u64, allocated: u64) -> u64 {
match size {
SizeMetric::Apparent => apparent,
SizeMetric::Allocated => allocated,
}
}
fn plural<'a>(count: u64, singular: &'a str, plural: &'a str) -> &'a str {
if count == 1 { singular } else { plural }
}
fn ratio(part: u64, whole: u64) -> f64 {
if whole == 0 {
return 0.0;
}
#[allow(clippy::cast_precision_loss)]
let share = part as f64 / whole as f64;
share.clamp(0.0, 1.0)
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss)]
fn bar(share: f64, color: bool) -> String {
const WIDTH: usize = 10;
let filled = ((share.clamp(0.0, 1.0) * WIDTH as f64).round() as usize).min(WIDTH);
let rendered = format!("{}{}", "â–ˆ".repeat(filled), "â–‘".repeat(WIDTH - filled));
paint(&rendered, STYLE_BAR, color)
}
pub fn human_count(value: u64) -> String {
let digits = value.to_string();
let mut grouped = String::with_capacity(digits.len() + digits.len() / 3);
for (index, byte) in digits.bytes().enumerate() {
if index > 0 && (digits.len() - index) % 3 == 0 {
grouped.push(',');
}
grouped.push(char::from(byte));
}
grouped
}
pub fn human_bytes(bytes: u64) -> String {
const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
let mut whole = bytes;
let mut remainder = 0u64;
let mut unit = 0;
while whole >= 1024 && unit + 1 < UNITS.len() {
remainder = whole % 1024;
whole /= 1024;
unit += 1;
}
if unit == 0 {
format!("{bytes} B")
} else if whole < 10 {
let tenths = (remainder * 10) / 1024;
format!("{whole}.{tenths} {}", UNITS[unit])
} else {
format!("{whole} {}", UNITS[unit])
}
}
pub fn is_lossy(path: &Path) -> bool {
path.to_str().is_none()
}
pub const STREAM_SCHEMA: &str = "fdu.stream/2";
pub fn watch_rule(at: std::time::SystemTime) -> String {
format!("──── {} ────", format_rfc3339(at))
}
pub fn watch_rule_nanos(at_nanos: i64) -> String {
format!("──── {} ────", format_rfc3339_nanos(at_nanos))
}
#[cfg(feature = "watch")]
pub fn render_change(change: &crate::Change, format: Format) -> String {
let kind = match change.kind {
crate::ChangeKind::Upsert => "upsert",
crate::ChangeKind::Remove => "remove",
crate::ChangeKind::Invalidate => "invalidate",
};
if !format.is_machine() {
return format!("{}\t{kind}", change.path.display());
}
match format {
Format::Json | Format::Jsonl => render_change_machine(change, kind, JsonSink::line()),
Format::Yaml => {
format!(
"{}{}",
document_start(format),
render_change_machine(change, kind, YamlSink::new())
)
}
Format::Text | Format::Tree | Format::Paths | Format::Long => {
unreachable!("text returned above")
}
}
}
#[cfg(feature = "watch")]
fn render_change_machine(
change: &crate::Change,
kind: &str,
mut sink: impl Sink<Output = String>,
) -> String {
sink.event(Event::BeginMap(Shape::Block));
emit_str_field(&mut sink, "schema", STREAM_SCHEMA);
emit_str_field(&mut sink, "record", "change");
emit_str_field(&mut sink, "op", kind);
emit_path_fields(&mut sink, &change.path);
emit_u64_field(&mut sink, "clock", change.clock);
emit_field(&mut sink, Field::when_set("kind"), change.entry_kind.is_some(), |sink| {
emit_scalar(sink, Scalar::Str(kind_label(change.entry_kind.expect("presence checked"))));
});
emit_field(&mut sink, Field::when_set("bytes"), change.bytes.is_some(), |sink| {
emit_scalar(sink, Scalar::U64(change.bytes.expect("presence checked")));
});
emit_field(&mut sink, Field::when_set("allocated"), change.allocated.is_some(), |sink| {
emit_scalar(sink, Scalar::U64(change.allocated.expect("presence checked")));
});
emit_field(&mut sink, Field::when_set("mtime_ns"), change.mtime_ns.is_some(), |sink| {
emit_scalar(sink, Scalar::I64(change.mtime_ns.expect("presence checked")));
});
emit_field(&mut sink, Field::when_set("ignored"), change.ignored.is_some(), |sink| {
emit_scalar(sink, Scalar::Bool(change.ignored.expect("presence checked")));
});
sink.event(Event::EndMap);
sink.finish()
}
fn raw_os_identity(value: &std::ffi::OsStr) -> Option<(&'static str, String)> {
if value.to_str().is_some() {
return None;
}
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
Some(("unix-bytes", hex_bytes(value.as_bytes().iter().copied())))
}
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
Some(("windows-wtf16le", hex_bytes(value.encode_wide().flat_map(u16::to_le_bytes))))
}
#[cfg(not(any(unix, windows)))]
{
None
}
}
#[cfg(any(unix, windows))]
fn hex_bytes(bytes: impl IntoIterator<Item = u8>) -> String {
let mut out = String::new();
for byte in bytes {
let _ = write!(out, "{byte:02x}");
}
out
}
pub fn render_cache_status(
statuses: &[crate::CacheStatus],
scope: crate::CacheScope,
format: Format,
) -> String {
match format {
Format::Jsonl => {
let mut sink = JsonSink::line();
sink.event(Event::BeginMap(Shape::Inline));
emit_str_field(&mut sink, "schema", CACHE_SCHEMA);
sink.event(Event::EndMap);
let mut out = sink.finish();
for status in statuses {
out.push('\n');
let row = cache_row(status);
let mut sink = JsonSink::line();
emit_cache_field(&mut sink, &row);
out.push_str(&sink.finish());
}
out
}
Format::Yaml => render_cache_machine(statuses, YamlSink::new()),
Format::Text | Format::Tree | Format::Paths | Format::Long => {
render_cache_status_text(statuses, scope)
}
Format::Json => render_cache_machine(statuses, JsonSink::pretty()),
}
}
fn render_cache_machine(
statuses: &[crate::CacheStatus],
mut sink: impl Sink<Output = String>,
) -> String {
sink.event(Event::BeginMap(Shape::Block));
emit_str_field(&mut sink, "schema", CACHE_SCHEMA);
emit_field(&mut sink, Field::always("caches"), true, |sink| {
sink.event(Event::BeginSeq(Shape::Block));
for status in statuses {
let row = cache_row(status);
emit_cache_field(sink, &row);
}
sink.event(Event::EndSeq);
});
sink.event(Event::EndMap);
sink.finish()
}
fn emit_cache_field(sink: &mut impl Sink, field: &CacheField) {
match field {
CacheField::Null => emit_scalar(sink, Scalar::Null),
CacheField::Bool(value) => emit_scalar(sink, Scalar::Bool(*value)),
CacheField::Count(value) => emit_scalar(sink, Scalar::U64(*value)),
CacheField::Text(value) => emit_scalar(sink, Scalar::Str(value)),
CacheField::List(values) => {
sink.event(Event::BeginSeq(Shape::Block));
for value in values {
emit_cache_field(sink, value);
}
sink.event(Event::EndSeq);
}
CacheField::Map(fields) => {
sink.event(Event::BeginMap(Shape::Block));
for (name, value) in fields {
sink.event(Event::Key(name));
emit_cache_field(sink, value);
}
sink.event(Event::EndMap);
}
}
}
enum CacheField {
Null,
Bool(bool),
Count(u64),
Text(String),
List(Vec<CacheField>),
Map(Vec<(&'static str, CacheField)>),
}
impl CacheField {
fn count(value: Option<u64>) -> Self {
value.map_or(Self::Null, Self::Count)
}
}
fn cache_row(status: &crate::CacheStatus) -> CacheField {
use crate::CacheState;
let mut fields = vec![("path", CacheField::Text(status.path.to_string_lossy().into_owned()))];
if let Some((encoding, hex)) = raw_os_identity(status.path.as_os_str()) {
fields.push((
"path_raw",
CacheField::Map(vec![
("encoding", CacheField::Text(encoding.to_string())),
("hex", CacheField::Text(hex)),
]),
));
}
fields.extend([
("bytes", CacheField::Count(status.bytes)),
("state", CacheField::Text(status.state.label().to_string())),
]);
match &status.state {
CacheState::Current(info) => {
fields.push(("root", CacheField::Text(info.root.to_string_lossy().into_owned())));
if let Some((encoding, hex)) = raw_os_identity(info.root.as_os_str()) {
fields.push((
"root_raw",
CacheField::Map(vec![
("encoding", CacheField::Text(encoding.to_string())),
("hex", CacheField::Text(hex)),
]),
));
}
fields.push(("entries", CacheField::Count(info.entries)));
fields.push(("identity", snapshot_identity_field(info.identity)));
}
CacheState::Stale(reason) => fields.extend(stale_fields(*reason)),
CacheState::Leftover(kind) => {
fields.push(("leftover_kind", CacheField::Text(kind.label().to_string())));
}
CacheState::Unrecognized | CacheState::Absent => {}
}
fields.push(("content", status.content.as_ref().map_or(CacheField::Null, content_field)));
CacheField::Map(fields)
}
fn stale_fields(reason: crate::StaleReason) -> [(&'static str, CacheField); 2] {
[
("stale_reason", CacheField::Text(reason.label().to_string())),
("format_version", CacheField::count(reason.format_version().map(u64::from))),
]
}
fn content_field(content: &crate::ContentStatus) -> CacheField {
use crate::ContentState;
let mut fields = vec![
("bytes", CacheField::Count(content.bytes)),
("state", CacheField::Text(content.state.label().to_string())),
];
match &content.state {
ContentState::Current(info) => {
fields.push(("records", CacheField::Count(info.records)));
fields.push(("identity", content_identity_field(&info.identity)));
}
ContentState::Stale(reason) => fields.extend(stale_fields(*reason)),
}
CacheField::Map(fields)
}
fn snapshot_identity_field(identity: crate::SnapshotIdentity) -> CacheField {
let ignore_rules = match identity.controls {
crate::ControlTierIdentity::NotObserved => CacheField::Null,
crate::ControlTierIdentity::Observed { limits } => {
let limit = |limit: Option<usize>| {
CacheField::count(limit.map(|limit| u64::try_from(limit).unwrap_or(u64::MAX)))
};
CacheField::Map(vec![(
"limits",
CacheField::Map(vec![
("budget", limit(limits.budget)),
("line_limit", limit(limits.line_limit)),
]),
)])
}
};
CacheField::Map(vec![
("entries", entry_identity_field(identity.entries)),
("ignore_rules", ignore_rules),
])
}
fn entry_identity_field(identity: crate::EntryTierIdentity) -> CacheField {
let scope = identity.scope;
let depth = scope.max_depth.map(|depth| u64::try_from(depth).unwrap_or(u64::MAX));
CacheField::Map(vec![
("engine", CacheField::Count(identity.engine)),
("max_depth", CacheField::count(depth)),
("follow_symlinks", CacheField::Bool(scope.follow_symlinks)),
("one_filesystem", CacheField::Bool(scope.one_filesystem)),
("hidden_fingerprint", CacheField::Count(scope.hidden_fingerprint)),
("exclude_special", CacheField::Bool(scope.exclude_special)),
("type_rules_fingerprint", CacheField::Count(identity.type_rules_fingerprint)),
("reducers_fingerprint", CacheField::Count(identity.reducers_fingerprint)),
])
}
fn content_identity_field(identity: &crate::ContentTierIdentity) -> CacheField {
let analyze = analysis_set_labels(identity.analysis)
.into_iter()
.map(|label| CacheField::Text(label.to_string()))
.collect();
let analyzers = identity
.provenance
.analyzers
.iter()
.map(|(id, version)| {
CacheField::Map(vec![
("id", CacheField::Text(id.0.to_string())),
("version", CacheField::Count(u64::from(version.0))),
])
})
.collect();
CacheField::Map(vec![
("entries", entry_identity_field(identity.entries)),
("analyze", CacheField::List(analyze)),
("options_fingerprint", CacheField::Count(identity.provenance.options_fingerprint.0)),
("analyzers", CacheField::List(analyzers)),
])
}
fn render_cache_status_text(statuses: &[crate::CacheStatus], scope: crate::CacheScope) -> String {
use crate::{CacheScope, CacheState, LeftoverKind, StaleReason};
let mut lines = Vec::new();
let mut current = 0_usize;
let (mut stale, mut stale_bytes) = (0_usize, 0_u64);
let (mut leftover, mut leftover_bytes, mut staging) = (0_usize, 0_u64, 0_usize);
let (mut unrecognized, mut unrecognized_bytes) = (0_usize, 0_u64);
for status in statuses {
let content_bytes = status.content_bytes().unwrap_or(0);
match &status.state {
CacheState::Current(info) => {
current += 1;
let stale_content = status
.content
.as_ref()
.is_some_and(|content| matches!(content.state, crate::ContentState::Stale(_)));
lines.push(format!(
"{} {} entries, {} metadata bytes, {content_bytes} {}content bytes {}",
status.path.display(),
info.entries,
status.bytes,
if stale_content { "stale " } else { "" },
info.root.display()
));
}
CacheState::Stale(reason) => {
stale += 1;
stale_bytes =
stale_bytes.saturating_add(status.bytes).saturating_add(content_bytes);
let why = match reason {
StaleReason::OlderFormat { version } => {
format!("older snapshot format {version}")
}
StaleReason::NewerFormat { version } => {
format!("newer snapshot format {version}")
}
StaleReason::OtherEngine => "written by another fdu version".to_string(),
StaleReason::Unreadable => "unreadable by this build".to_string(),
};
lines.push(format!(
"{} stale ({why}), {} metadata bytes, {content_bytes} content bytes",
status.path.display(),
status.bytes
));
}
CacheState::Leftover(kind) => {
leftover += 1;
leftover_bytes = leftover_bytes.saturating_add(status.bytes);
let what = match kind {
LeftoverKind::StagingTemporary => {
staging += 1;
"staging temporary"
}
LeftoverKind::OrphanedContent => "orphaned content sidecar",
};
lines.push(format!(
"{} leftover ({what}), {} bytes",
status.path.display(),
status.bytes
));
}
CacheState::Unrecognized => {
unrecognized += 1;
unrecognized_bytes = unrecognized_bytes.saturating_add(status.bytes);
lines.push(format!(
"{} unrecognized, {} bytes",
status.path.display(),
status.bytes
));
}
CacheState::Absent => {}
}
}
if lines.is_empty() {
return "No cached snapshots.".to_string();
}
if stale > 0 {
let (subject, object) = if stale == 1 {
("1 stale snapshot".to_string(), "it")
} else {
(format!("{stale} stale snapshots"), "them")
};
let remedy = match scope {
CacheScope::Root => format!("fdu --cache-clear PATH removes {object}"),
CacheScope::All if current == 0 => format!("fdu --cache-clear=all removes {object}"),
CacheScope::All => {
format!("fdu --cache-clear=all removes {object}, along with every current snapshot")
}
};
lines.push(format!(
"{subject} ({stale_bytes} bytes) cannot be served by this build; {remedy}."
));
}
if leftover > 0 {
let (subject, predicate, object) = if leftover == 1 {
("1 leftover file".to_string(), "is", "it")
} else {
(format!("{leftover} leftover files"), "are", "them")
};
let caveat = if staging > 0 {
", though a staging file waits until it is too old to be a running writer's"
} else {
""
};
lines.push(format!(
"{subject} ({leftover_bytes} bytes) {predicate} fdu's own, left by an interrupted \
write; fdu --cache-clear=all reclaims {object}{caveat}."
));
}
if unrecognized > 0 {
let (subject, predicate, object) = if unrecognized == 1 {
("1 unrecognized file".to_string(), "is not an fdu snapshot", "it")
} else {
(format!("{unrecognized} unrecognized files"), "are not fdu snapshots", "them")
};
lines.push(format!(
"{subject} ({unrecognized_bytes} bytes) {predicate}, so fdu leaves {object} in place."
));
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
fn render(report: &Report, format: Format, color: bool) -> String {
super::render(report, format, color).expect("compatible report format")
}
use super::*;
use crate::Index;
use crate::engine_contract::{Attrs, Observation, Op, ScanScope};
use crate::query::{Bound, Query, Request, Selection};
use std::ffi::OsStr;
use std::path::PathBuf;
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
struct Provenance {
scan_started_at: Option<SystemTime>,
generated_at: SystemTime,
source: ReportSource,
complete: bool,
errors: Vec<String>,
}
fn report(index: &Index, request: &Request, provenance: &Provenance) -> crate::Result<Report> {
let mut report = crate::query::report(index, request, provenance.generated_at)?;
report.provenance.scan_started_at = provenance.scan_started_at;
report.provenance.source = provenance.source;
report.status.complete = provenance.complete;
report.status.errors = provenance
.errors
.iter()
.cloned()
.map(|message| crate::Issue::provider_failure(None, message))
.collect();
Ok(report)
}
fn attrs(size: u64, mtime_ns: i64) -> Attrs {
Attrs {
size,
allocated: size.div_ceil(512) * 512,
mtime_ns,
ctime_ns: mtime_ns,
inode: 7,
dev: 1,
}
}
fn cache_file(name: &str, bytes: u64, state: crate::CacheState) -> crate::CacheStatus {
let content =
matches!(state, crate::CacheState::Stale(_)).then_some(crate::ContentStatus {
bytes: 5,
state: crate::ContentState::Stale(crate::StaleReason::OlderFormat { version: 4 }),
});
crate::CacheStatus { path: PathBuf::from(name), bytes, content, state }
}
fn small_snapshot_identity() -> crate::SnapshotIdentity {
crate::SnapshotIdentity {
entries: crate::EntryTierIdentity {
engine: 1,
scope: crate::EntryScope {
max_depth: None,
follow_symlinks: false,
one_filesystem: true,
hidden_fingerprint: 2,
exclude_special: false,
},
type_rules_fingerprint: 3,
reducers_fingerprint: 4,
},
controls: crate::ControlTierIdentity::Observed {
limits: crate::control::ControlLimits { budget: Some(10), line_limit: None },
},
}
}
#[test]
fn cache_status_shows_stale_and_unrecognized_files_with_their_remedy() {
use crate::{CacheScope, CacheState, LeftoverKind, StaleReason};
let stale = [
cache_file("a.fdu", 10, CacheState::Stale(StaleReason::OlderFormat { version: 2 })),
cache_file("b.fdu", 20, CacheState::Stale(StaleReason::NewerFormat { version: 99 })),
cache_file("c.fdu", 30, CacheState::Stale(StaleReason::OtherEngine)),
cache_file("d.fdu", 40, CacheState::Stale(StaleReason::Unreadable)),
cache_file("notes.txt", 14, CacheState::Unrecognized),
];
assert_eq!(
render_cache_status(&stale, CacheScope::All, Format::Text),
"a.fdu stale (older snapshot format 2), 10 metadata bytes, 5 content bytes\n\
b.fdu stale (newer snapshot format 99), 20 metadata bytes, 5 content bytes\n\
c.fdu stale (written by another fdu version), 30 metadata bytes, 5 content bytes\n\
d.fdu stale (unreadable by this build), 40 metadata bytes, 5 content bytes\n\
notes.txt unrecognized, 14 bytes\n\
4 stale snapshots (120 bytes) cannot be served by this build; \
fdu --cache-clear=all removes them.\n\
1 unrecognized file (14 bytes) is not an fdu snapshot, so fdu leaves it in place."
);
let leftovers = [
cache_file(
".g.fdu.tmp.1.2.3",
60,
CacheState::Leftover(LeftoverKind::StagingTemporary),
),
cache_file("h.fdu.content", 70, CacheState::Leftover(LeftoverKind::OrphanedContent)),
];
assert_eq!(
render_cache_status(&leftovers, CacheScope::All, Format::Text),
".g.fdu.tmp.1.2.3 leftover (staging temporary), 60 bytes\n\
h.fdu.content leftover (orphaned content sidecar), 70 bytes\n\
2 leftover files (130 bytes) are fdu's own, left by an interrupted write; \
fdu --cache-clear=all reclaims them, though a staging file waits until it is \
too old to be a running writer's."
);
assert!(render_cache_status(&leftovers[..1], CacheScope::Root, Format::Text).ends_with(
"1 leftover file (60 bytes) is fdu's own, left by an interrupted write; \
fdu --cache-clear=all reclaims it, though a staging file waits until it \
is too old to be a running writer's."
));
assert!(render_cache_status(&leftovers[1..], CacheScope::All, Format::Text).ends_with(
"1 leftover file (70 bytes) is fdu's own, left by an interrupted write; \
fdu --cache-clear=all reclaims it."
));
let root = [cache_file("a.fdu", 10, CacheState::Stale(StaleReason::OtherEngine))];
assert!(render_cache_status(&root, CacheScope::Root, Format::Text).ends_with(
"1 stale snapshot (15 bytes) cannot be served by this build; \
fdu --cache-clear PATH removes it."
));
let current = cache_file(
"e.fdu",
50,
CacheState::Current(crate::SnapshotInfo {
root: PathBuf::from("/tree"),
identity: small_snapshot_identity(),
entries: 3,
}),
);
let mixed = [stale[0].clone(), current, stale[4].clone(), stale[4].clone()];
assert!(render_cache_status(&mixed, CacheScope::All, Format::Text).ends_with(
"e.fdu 3 entries, 50 metadata bytes, 0 content bytes /tree\n\
notes.txt unrecognized, 14 bytes\n\
notes.txt unrecognized, 14 bytes\n\
1 stale snapshot (15 bytes) cannot be served by this build; \
fdu --cache-clear=all removes it, along with every current snapshot.\n\
2 unrecognized files (28 bytes) are not fdu snapshots, so fdu leaves them in place."
));
let absent = [cache_file("f.fdu", 0, CacheState::Absent)];
assert_eq!(
render_cache_status(&absent, CacheScope::Root, Format::Text),
"No cached snapshots."
);
assert_eq!(
render_cache_status(
&[stale[0].clone(), absent[0].clone(), stale[4].clone(), leftovers[0].clone()],
CacheScope::All,
Format::Jsonl
),
"{\"schema\": \"fdu.cache/2\"}\n\
{\"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\
{\"path\": \"f.fdu\", \"bytes\": 0, \"state\": \"absent\", \"content\": null}\n\
{\"path\": \"notes.txt\", \"bytes\": 14, \"state\": \"unrecognized\", \"content\": null}\n\
{\"path\": \".g.fdu.tmp.1.2.3\", \"bytes\": 60, \"state\": \"leftover\", \"leftover_kind\": \"staging_temporary\", \"content\": null}"
);
assert_eq!(
render_cache_status(&[], CacheScope::All, Format::Jsonl),
"{\"schema\": \"fdu.cache/2\"}"
);
assert_eq!(
render_cache_status(&[], CacheScope::All, Format::Json),
"{\n \"schema\": \"fdu.cache/2\",\n \"caches\": []\n}\n"
);
assert_eq!(
render_cache_status(&[], CacheScope::All, Format::Yaml),
"schema: fdu.cache/2\ncaches: []\n"
);
assert!(
render_cache_status(&stale[2..3], CacheScope::All, Format::Json)
.starts_with("{\n \"schema\": \"fdu.cache/2\",\n \"caches\": [\n {")
);
assert!(
render_cache_status(&stale[2..3], CacheScope::All, Format::Yaml)
.starts_with("schema: fdu.cache/2\ncaches:\n -\n path: c.fdu")
);
assert!(
render_cache_status(&stale[2..3], CacheScope::All, Format::Yaml).ends_with(
"state: stale\n stale_reason: other_engine\n format_version: null\n \
content:\n bytes: 5\n state: stale\n stale_reason: older_format\n \
format_version: 4\n"
)
);
assert!(render_cache_status(&leftovers[1..], CacheScope::All, Format::Yaml).ends_with(
"state: leftover\n leftover_kind: orphaned_content\n content: null\n"
));
}
#[test]
fn cache_status_carries_every_stored_tier_identity() {
use crate::{CacheScope, CacheState, ContentInfo, ContentState, ContentStatus};
let snapshot = small_snapshot_identity();
let content = crate::ContentTierIdentity {
entries: snapshot.entries,
analysis: crate::content::AnalysisSet::NONE.with_lines(),
provenance: crate::AnalyzerProvenance {
options_fingerprint: crate::content::OptionsFingerprint(5),
analyzers: vec![(
crate::content::CONTENT_BASIC,
crate::content::AnalyzerVersion(1),
)],
},
};
let status = crate::CacheStatus {
path: PathBuf::from("e.fdu"),
bytes: 50,
content: Some(ContentStatus {
bytes: 9,
state: ContentState::Current(ContentInfo { identity: content, records: 2 }),
}),
state: CacheState::Current(crate::SnapshotInfo {
root: PathBuf::from("/tree"),
identity: snapshot,
entries: 3,
}),
};
let entries = "{\"engine\": 1, \"max_depth\": null, \"follow_symlinks\": false, \
\"one_filesystem\": true, \"hidden_fingerprint\": 2, \"exclude_special\": false, \
\"type_rules_fingerprint\": 3, \"reducers_fingerprint\": 4}";
assert_eq!(
render_cache_status(std::slice::from_ref(&status), CacheScope::Root, Format::Jsonl),
format!(
"{{\"schema\": \"fdu.cache/2\"}}\n\
{{\"path\": \"e.fdu\", \"bytes\": 50, \"state\": \"current\", \"root\": \"/tree\", \
\"entries\": 3, \"identity\": {{\"entries\": {entries}, \"ignore_rules\": \
{{\"limits\": {{\"budget\": 10, \"line_limit\": null}}}}}}, \"content\": {{\"bytes\": 9, \
\"state\": \"current\", \"records\": 2, \"identity\": {{\"entries\": {entries}, \
\"analyze\": [\"lines\"], \"options_fingerprint\": 5, \
\"analyzers\": [{{\"id\": \"content-basic-v1\", \"version\": 1}}]}}}}}}"
)
);
let entries = "\n engine: 1\n max_depth: null\n \
follow_symlinks: false\n one_filesystem: true\n \
hidden_fingerprint: 2\n exclude_special: false\n \
type_rules_fingerprint: 3\n reducers_fingerprint: 4";
assert_eq!(
render_cache_status(std::slice::from_ref(&status), CacheScope::Root, Format::Yaml),
format!(
"schema: fdu.cache/2\ncaches:\n -\n path: e.fdu\n bytes: 50\n state: current\n \
root: /tree\n entries: 3\n identity:\n entries:{}\n \
ignore_rules:\n limits:\n budget: 10\n line_limit: null\n \
content:\n bytes: 9\n state: current\n records: 2\n identity:\n \
entries:{entries}\n analyze:\n - lines\n \
options_fingerprint: 5\n analyzers:\n -\n id: content-basic-v1\n \
version: 1\n",
entries.replace("\n ", "\n")
)
);
let stale_content = crate::CacheStatus {
content: Some(ContentStatus {
bytes: 9,
state: ContentState::Stale(crate::StaleReason::OtherEngine),
}),
..status
};
assert_eq!(
render_cache_status(&[stale_content], CacheScope::Root, Format::Text),
"e.fdu 3 entries, 50 metadata bytes, 9 stale content bytes /tree"
);
}
#[test]
fn the_cache_schema_constant_is_the_versioning_promise() {
assert_eq!(CACHE_SCHEMA, "fdu.cache/2");
for format in [Format::Json, Format::Jsonl, Format::Yaml] {
let rendered = render_cache_status(&[], crate::CacheScope::All, format);
assert!(rendered.contains(CACHE_SCHEMA), "{format:?} carries no schema: {rendered}");
}
}
#[cfg(all(unix, feature = "watch"))]
#[test]
fn cache_and_change_rows_preserve_non_unicode_raw_paths() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let path = PathBuf::from(OsString::from_vec(vec![b'n', 0x80]));
let status = crate::CacheStatus {
path: path.clone(),
bytes: 3,
content: None,
state: crate::CacheState::Unrecognized,
};
for format in [Format::Json, Format::Jsonl, Format::Yaml] {
let rendered =
render_cache_status(std::slice::from_ref(&status), crate::CacheScope::All, format);
assert!(rendered.contains("path_raw"), "{format:?}: {rendered}");
assert!(rendered.contains("6e80"), "{format:?}: {rendered}");
}
let change = crate::Change {
path,
kind: crate::ChangeKind::Remove,
entry_kind: None,
bytes: None,
allocated: None,
mtime_ns: None,
ignored: None,
clock: 1,
};
for format in [Format::Json, Format::Jsonl, Format::Yaml] {
let rendered = render_change(&change, format);
assert!(rendered.contains("path_raw"), "{format:?}: {rendered}");
assert!(rendered.contains("6e80"), "{format:?}: {rendered}");
}
}
#[test]
fn a_bound_states_itself_and_states_it_accurately() {
let report = fixture(&[ViewSpec::Files]);
let Section::Files { rows, total, .. } = &report.sections[0] else {
panic!("expected a files section");
};
let full = rows.len();
assert_eq!(*total, full, "an unbounded view drops nothing");
assert!(!render(&report, Format::Text, false).contains("--limit all"), "and says nothing");
assert!(render(&report, Format::Json, false).contains("\"bound\": null"));
let mut query = Query { views: vec![ViewSpec::Files], ..Query::default() };
query.selection.limit = Some(Bound::Limit(1));
let bounded = fixture_for(&query);
let Section::Files { rows, total, .. } = &bounded.sections[0] else {
panic!("expected a files section");
};
assert_eq!(rows.len(), 1);
assert_eq!(*total, full, "the total is what there was, not what was kept");
let text = render(&bounded, Format::Text, false);
assert!(text.contains(&format!("(1 of {full}")), "the header states the bound: {text}");
assert!(text.contains("--limit all"), "and names the flag that lifts it: {text}");
let json = render(&bounded, Format::Json, false);
assert!(json.contains(&format!("\"shown\": 1, \"total\": {full}")), "{json:.200}");
let yaml = render(&bounded, Format::Yaml, false);
assert!(yaml.contains("shown: 1"), "{yaml:.200}");
}
#[test]
fn colour_never_changes_the_layout_of_any_view() {
fn strip_ansi(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
for escape in chars.by_ref() {
if escape.is_ascii_alphabetic() {
break;
}
}
} else {
out.push(c);
}
}
out
}
for view in [
ViewSpec::Tree,
ViewSpec::Extensions,
ViewSpec::Types,
ViewSpec::Families,
ViewSpec::Languages,
ViewSpec::Files,
ViewSpec::Summary,
] {
let report = fixture(&[view]);
let plain = render(&report, Format::Text, false);
let coloured = render(&report, Format::Text, true);
let styles_a_label = !matches!(view, ViewSpec::Files | ViewSpec::Summary);
assert_eq!(
plain != coloured,
styles_a_label,
"{view:?} disagrees with whether it styles a label"
);
assert_eq!(
strip_ansi(&coloured),
plain,
"{view:?} lays out differently once colour is on"
);
}
}
#[test]
fn a_label_cell_is_measured_on_visible_text() {
let plain = label_cell("md", 6, STYLE_TYPE, false);
let coloured = label_cell("md", 6, STYLE_TYPE, true);
assert_eq!(plain, "md ", "four spaces of padding");
assert!(coloured.starts_with('\u{1b}'), "the label is styled");
assert!(coloured.ends_with(" "), "and padded by the same four: {coloured:?}");
assert_eq!(label_cell("verylonglabel", 4, STYLE_TYPE, false), "verylonglabel");
}
const ALL_TEST_VIEWS: [ViewSpec; 11] = [
ViewSpec::List,
ViewSpec::Tree,
ViewSpec::Types,
ViewSpec::Extensions,
ViewSpec::Families,
ViewSpec::Languages,
ViewSpec::Documents,
ViewSpec::Files,
ViewSpec::Largest,
ViewSpec::Recent,
ViewSpec::Summary,
];
struct SchemaCheck<S> {
inner: S,
expected: Vec<&'static str>,
next: usize,
depth: usize,
}
impl<S: Sink> SchemaCheck<S> {
fn report(inner: S, lossy: bool, sections: bool) -> Self {
let fields = &REPORT_FIELDS;
let ordered = [
fields.schema,
fields.generator,
fields.root,
fields.root_raw,
fields.age_reference_ns,
fields.request,
fields.status,
fields.provenance,
fields.ignore_rules,
fields.analysis,
fields.reports,
];
let expected = ordered
.into_iter()
.filter_map(|field| {
let present = match field.presence {
Presence::Always | Presence::Nullable => true,
Presence::WhenLossy => lossy,
Presence::WhenSet => sections,
Presence::WhenAnalyzer(_) => panic!("envelope has no analyzer-owned field"),
};
present.then_some(field.name)
})
.collect();
Self { inner, expected, next: 0, depth: 0 }
}
}
impl<S: Sink> Sink for SchemaCheck<S> {
type Output = S::Output;
fn event(&mut self, event: Event<'_>) {
match event {
Event::BeginMap(_) | Event::BeginSeq(_) => self.depth += 1,
Event::EndMap | Event::EndSeq => self.depth -= 1,
Event::Key(name) if self.depth == 1 => {
assert_eq!(
Some(&name),
self.expected.get(self.next),
"wire field order/presence"
);
self.next += 1;
}
Event::Key(_) | Event::Scalar(_) => {}
}
self.inner.event(event);
}
fn finish(self) -> Self::Output {
assert_eq!(self.depth, 0, "unclosed collection");
assert_eq!(self.next, self.expected.len(), "required field missing");
self.inner.finish()
}
}
#[test]
fn report_walk_obeys_declared_field_order_and_presence_for_every_writer() {
for view in [ViewSpec::Summary, ViewSpec::Documents] {
let report = fixture(&[view]);
for sections in [false, true] {
let mut json = SchemaCheck::report(JsonSink::pretty(), false, sections);
emit_report(&mut json, &report, sections);
assert!(!json.finish().is_empty());
let mut line = SchemaCheck::report(JsonSink::line(), false, sections);
emit_report(&mut line, &report, sections);
assert!(!line.finish().is_empty());
let mut yaml = SchemaCheck::report(YamlSink::new(), false, sections);
emit_report(&mut yaml, &report, sections);
assert!(!yaml.finish().is_empty());
}
}
}
#[test]
#[should_panic(expected = "required field missing")]
fn schema_check_rejects_a_missing_required_field() {
let mut check = SchemaCheck::report(JsonSink::line(), false, true);
check.event(Event::BeginMap(Shape::Inline));
check.event(Event::EndMap);
check.finish();
}
#[test]
fn list_formats_preserve_the_default_tree_and_expose_flat_subtree_metrics() {
let legacy = fixture(&[ViewSpec::Tree]);
let list = fixture(&[ViewSpec::List]);
assert_eq!(
super::render(&legacy, Format::Text, false).expect("tree"),
super::render(&list, Format::Tree, false).expect("list tree")
);
assert!(
super::render(&list, Format::Paths, false).is_err(),
"folding cannot silently become an inventory"
);
let mut rejected = Vec::new();
assert_eq!(
write(&list, Format::Paths, false, &mut rejected)
.expect_err("folded projection")
.kind(),
io::ErrorKind::InvalidInput
);
assert!(rejected.is_empty(), "validate before writing any bytes");
let flat = fixture_for(&Query {
views: vec![ViewSpec::List],
format: Format::Paths,
selection: Selection {
kinds: vec![EntryKind::Dir],
size: SizeMetric::Apparent,
..Selection::default()
},
..Query::default()
});
assert_eq!(super::render(&flat, Format::Paths, false).expect("paths"), "src\n");
assert!(super::render(&flat, Format::Long, false).expect("long").contains("100 B"));
assert!(super::render(&flat, Format::Tree, false).is_err());
let Section::Files { rows, .. } = &flat.sections[0] else { panic!("flat list") };
assert_eq!((rows[0].files, rows[0].dirs, rows[0].mtime_ns), (Some(1), Some(0), 10));
assert_eq!(rows[0].complete, Some(true));
assert!(
super::render(&flat, Format::Json, false).expect("json").contains("\"complete\": true")
);
assert_eq!(
rows[0].age_ns,
flat.age_reference_ns.map(|reference| i128::from(reference) - 10)
);
for format in [Format::Json, Format::Jsonl, Format::Yaml] {
let wire = super::render(&flat, format, false).expect("serialization");
assert!(wire.contains("age_reference_ns"));
assert!(wire.contains("age_ns"));
}
assert_eq!(human_age(Some(-1)), "-0s");
assert_eq!(human_age(Some(30 * 86400 * 1_000_000_000)), "30d");
assert_eq!(human_age(None), "unknown");
assert_eq!(flat_path(Path::new("a\nb\tc")), "a\\nb\\tc");
assert_eq!(flat_path(Path::new("d/a\\b")), "d/a\\b");
let mut stale = flat.clone();
stale.provenance.source = ReportSource::CacheOnly;
stale.provenance.freshness = Freshness::Stale;
stale.scope.max_depth = Some(2);
let notes = flat_diagnostics(&stale).join("\n");
assert!(notes.contains("not been revalidated"));
assert!(notes.contains("freshness: stale"));
assert!(notes.contains("scan scope limited to depth 2"));
assert_eq!(super::render(&stale, Format::Paths, false).expect("paths"), "src\n");
}
#[test]
fn signed_ages_preserve_exact_endpoints_in_streaming_machine_formats() {
let mut report = fixture_for(&Query {
views: vec![ViewSpec::List],
format: Format::Json,
..Query::default()
});
for (reference, modified) in [(i64::MAX, i64::MIN), (i64::MIN, i64::MAX)] {
let age = i128::from(reference) - i128::from(modified);
report.age_reference_ns = Some(reference);
let Section::Files { rows, .. } = &mut report.sections[0] else { panic!("flat rows") };
rows[0].mtime_ns = modified;
rows[0].age_ns = Some(age);
for format in [Format::Json, Format::Jsonl, Format::Yaml] {
let rendered = super::render(&report, format, false).expect("machine format");
assert!(rendered.contains(&age.to_string()), "{format:?} lost exact signed age");
let mut streamed = Vec::new();
write(&report, format, false, &mut streamed).expect("streaming writer");
assert_eq!(streamed, rendered.as_bytes());
}
}
}
fn fixture(views: &[ViewSpec]) -> Report {
fixture_for(&Query { views: views.to_vec(), ..Query::default() })
}
fn fixture_for(query: &Query) -> Report {
let mut index = Index::new_with_scope("/root", ScanScope::default());
if query.views.contains(&ViewSpec::Documents) {
index.prepare_content_analysis(crate::content::AnalysisRequest {
profile: crate::content::AnalysisSet::NONE.with_words(),
..crate::content::AnalysisRequest::default()
});
}
index
.apply(&Observation::new(vec![
Op::Upsert {
path: PathBuf::from("src"),
kind: EntryKind::Dir,
attrs: Attrs::default(),
},
Op::Upsert {
path: PathBuf::from("src/main.rs"),
kind: EntryKind::File,
attrs: attrs(100, 10),
},
Op::Upsert {
path: PathBuf::from("notes.md"),
kind: EntryKind::File,
attrs: attrs(20, 20),
},
]))
.expect("apply");
report(
&index,
&crate::test_support::read_of(&index, query.clone()),
&Provenance {
scan_started_at: Some(UNIX_EPOCH + Duration::from_secs(1_786_386_151)),
generated_at: UNIX_EPOCH + Duration::from_secs(1_786_386_152),
source: ReportSource::ColdScan,
complete: true,
errors: Vec::new(),
},
)
.expect("report")
}
fn is_view_header_line(line: &str) -> bool {
!line.is_empty() && line.chars().all(|c| c.is_ascii_uppercase())
}
fn is_valid_json(text: &str) -> bool {
let (mut depth, mut in_string, mut escaped) = (0i32, false, false);
for ch in text.chars() {
if in_string {
match ch {
_ if escaped => escaped = false,
'\\' => escaped = true,
'"' => in_string = false,
_ => {}
}
continue;
}
match ch {
'"' => in_string = true,
'{' | '[' => depth += 1,
'}' | ']' => {
depth -= 1;
if depth < 0 {
return false;
}
}
_ => {}
}
}
depth == 0 && !in_string
}
fn compact_json(text: &str) -> String {
let mut compact = String::with_capacity(text.len());
let (mut in_string, mut escaped) = (false, false);
for ch in text.chars() {
if in_string {
compact.push(ch);
match ch {
_ if escaped => escaped = false,
'\\' => escaped = true,
'"' => in_string = false,
_ => {}
}
} else if ch == '"' {
in_string = true;
compact.push(ch);
} else if !ch.is_ascii_whitespace() {
compact.push(ch);
}
}
compact
}
#[test]
fn every_view_renders_in_every_format() {
for view in ALL_TEST_VIEWS {
let report = fixture(&[view]);
for format in [Format::Text, Format::Json, Format::Jsonl, Format::Yaml] {
let rendered = render(&report, format, false);
assert!(!rendered.trim().is_empty(), "{view:?} in {format:?} rendered nothing");
if format != Format::Text {
assert!(
rendered.contains("\"schema\"") || rendered.contains("schema:"),
"{view:?} as {format:?} carries no schema: {rendered:.120}"
);
}
}
}
}
#[test]
fn text_tree_restores_compact_bars_and_keeps_structural_indent_in_the_name_column() {
let apparent = Query {
views: vec![ViewSpec::Tree],
selection: crate::query::Selection {
size: crate::query::SizeMetric::Apparent,
..crate::query::Selection::default()
},
..Query::default()
};
let text = render(&fixture_for(&apparent), Format::Text, false);
assert_eq!(
text,
concat!(
" 120 B ██████████ 100% . (2 files)\n",
" 100 B ████████░░ 83% src (1 file)\n",
)
);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines[0].find("120 B"), lines[1].find("100 B"));
assert_eq!(lines[0].find('â–ˆ'), lines[1].find('â–ˆ'));
}
#[test]
fn language_text_uses_human_names_and_aligns_suffixes_with_color() {
let mut index = Index::new_with_scope("/root", ScanScope::default());
index
.apply(&Observation::new(vec![
Op::Upsert {
path: PathBuf::from("main.cpp"),
kind: EntryKind::File,
attrs: attrs(100, 10),
},
Op::Upsert {
path: PathBuf::from("main.js"),
kind: EntryKind::File,
attrs: attrs(100, 20),
},
]))
.expect("apply");
let report = report(
&index,
&crate::test_support::read_of(
&index,
Query { views: vec![ViewSpec::Languages], ..Query::default() },
),
&Provenance {
scan_started_at: None,
generated_at: UNIX_EPOCH,
source: ReportSource::ColdScan,
complete: true,
errors: Vec::new(),
},
)
.expect("report");
let plain = render(&report, Format::Text, false);
assert!(plain.contains("C++"), "{plain}");
assert!(plain.contains("JavaScript"), "{plain}");
let plain_suffixes = plain
.lines()
.map(|line| line.find("1 file").expect("file count suffix"))
.collect::<Vec<_>>();
assert_eq!(plain_suffixes[0], plain_suffixes[1], "{plain}");
let colored = render(&report, Format::Text, true);
let colored_suffixes = colored
.lines()
.map(|line| line.find("1 file").expect("colored file count suffix"))
.collect::<Vec<_>>();
assert_eq!(colored_suffixes[0], colored_suffixes[1], "{colored:?}");
let json = render(&report, Format::Json, false);
assert!(json.contains("\"id\": \"cpp\""), "{json}");
assert!(json.contains("\"id\": \"javascript\""), "{json}");
assert!(!json.contains("\"id\": \"C++\""), "{json}");
assert!(!json.contains("\"id\": \"JavaScript\""), "{json}");
}
#[test]
fn text_labels_a_percentage_that_is_not_a_byte_share() {
let mut languages = fixture(&[ViewSpec::Languages]);
if let Section::Metrics { summary, .. } = &mut languages.sections[0] {
summary.share_metric = ShareMetric::CodeLines;
} else {
panic!("languages should be a metric section");
}
let text = render(&languages, Format::Text, false);
assert!(text.starts_with("Percentage column: code lines\n"), "{text}");
let Section::Metrics { summary, .. } = &mut languages.sections[0] else {
unreachable!("languages should stay a metric section");
};
summary.share_metric = ShareMetric::AllocatedBytes;
let text = render(&languages, Format::Text, false);
assert!(!text.contains("Percentage column:"), "{text}");
let mut documents = fixture(&[ViewSpec::Documents]);
let Section::Metrics { summary, .. } = &mut documents.sections[0] else {
panic!("documents should be a metric section");
};
summary.share_metric = ShareMetric::DocumentWords;
let text = render(&documents, Format::Text, false);
assert!(text.starts_with("Percentage column: document words\n"), "{text}");
}
#[test]
fn json_output_is_well_formed_for_every_view() {
for view in [
ViewSpec::Tree,
ViewSpec::Extensions,
ViewSpec::Types,
ViewSpec::Families,
ViewSpec::Languages,
ViewSpec::Documents,
ViewSpec::Files,
ViewSpec::Summary,
] {
let json = render(&fixture(&[view]), Format::Json, false);
assert!(is_valid_json(&json), "unbalanced JSON for {view:?}:\n{json}");
}
let all = render(
&fixture(&[
ViewSpec::Tree,
ViewSpec::Extensions,
ViewSpec::Types,
ViewSpec::Files,
ViewSpec::Summary,
]),
Format::Json,
false,
);
assert!(is_valid_json(&all), "unbalanced JSON for a multi-view report:\n{all}");
}
#[test]
fn streaming_machine_writers_match_string_rendering() {
struct Fails;
impl std::io::Write for Fails {
fn write(&mut self, _buffer: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::other("closed"))
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let report = fixture(&[
ViewSpec::Tree,
ViewSpec::Extensions,
ViewSpec::Types,
ViewSpec::Files,
ViewSpec::Summary,
]);
for format in [Format::Json, Format::Jsonl, Format::Yaml] {
let expected = render(&report, format, false);
let mut streamed = Vec::new();
write(&report, format, false, &mut streamed).expect("stream report");
assert_eq!(streamed, expected.as_bytes(), "{format:?} bytes differ");
}
let error = write(&report, Format::Json, false, &mut Fails).expect_err("writer fails");
assert_eq!(error.kind(), std::io::ErrorKind::Other);
}
#[test]
fn streaming_tree_walk_handles_many_siblings_without_collecting_output() {
#[derive(Default)]
struct Count(u64);
impl std::io::Write for Count {
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
self.0 = self.0.saturating_add(buffer.len() as u64);
Ok(buffer.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut report = fixture(&[ViewSpec::Tree]);
let Section::Tree { root, .. } = &mut report.sections[0] else {
panic!("tree fixture must contain a tree");
};
let template = root.children[0].clone();
root.children = (0..10_000)
.map(|index| {
let mut child = template.clone();
child.name = format!("child-{index}");
child.path = PathBuf::from(&child.name);
child
})
.collect();
let mut output = Count::default();
write(&report, Format::Json, false, &mut output).expect("stream wide report");
assert!(output.0 > 1_000_000, "wide fixture must exercise substantial output");
}
#[test]
fn nested_json_separates_siblings_without_a_trailing_comma() {
let mut index = Index::new_with_scope("/root", ScanScope::default());
index
.apply(&Observation::new(vec![
Op::Upsert {
path: PathBuf::from("a"),
kind: EntryKind::Dir,
attrs: Attrs::default(),
},
Op::Upsert {
path: PathBuf::from("b"),
kind: EntryKind::Dir,
attrs: Attrs::default(),
},
Op::Upsert {
path: PathBuf::from("c"),
kind: EntryKind::Dir,
attrs: Attrs::default(),
},
Op::Upsert {
path: PathBuf::from("a/inner"),
kind: EntryKind::Dir,
attrs: Attrs::default(),
},
Op::Upsert {
path: PathBuf::from("a/other"),
kind: EntryKind::Dir,
attrs: Attrs::default(),
},
]))
.expect("apply");
let report = report(
&index,
&crate::test_support::read_of(
&index,
Query {
selection: Selection { depth: Some(Bound::All), ..Selection::default() },
views: vec![ViewSpec::Tree],
..Query::default()
},
),
&Provenance {
scan_started_at: None,
generated_at: UNIX_EPOCH,
source: ReportSource::ColdScan,
complete: true,
errors: Vec::new(),
},
)
.expect("report");
let json = render(&report, Format::Json, false);
assert!(is_valid_json(&json), "{json}");
assert!(!json.contains("}{"), "siblings must be separated:\n{json}");
assert!(!json.contains(",]"), "no trailing comma before a close:\n{json}");
assert!(!json.contains("[,"), "no leading comma after an open:\n{json}");
for name in ["\"a\"", "\"b\"", "\"c\"", "\"inner\"", "\"other\""] {
assert!(json.contains(name), "missing {name} in:\n{json}");
}
}
#[test]
fn jsonl_emits_one_document_per_line() {
let rendered =
render(&fixture(&[ViewSpec::Extensions, ViewSpec::Summary]), Format::Jsonl, false);
let lines: Vec<&str> = rendered.lines().collect();
assert_eq!(lines.len(), 3, "one envelope plus one line per section");
for line in &lines {
assert!(is_valid_json(line), "line is not a JSON document: {line}");
}
assert!(lines[0].contains("\"schema\""), "the envelope carries provenance");
}
#[test]
fn machine_output_carries_the_schema_and_provenance() {
let json = render(&fixture(&[ViewSpec::Summary]), Format::Json, false);
assert!(json.contains("\"schema\": \"fdu.report/7\""));
assert!(json.contains("\"request\": {"));
assert!(json.contains("\"status\": {"));
assert!(json.contains("\"provenance\": {"));
assert!(json.contains("\"source\": \"cold_scan\""));
assert!(json.contains("\"complete\": true"));
assert!(json.contains("\"scan_started_at\": \"2026-08-10T18:22:31.000000000Z\""), "{json}");
assert!(json.contains("\"generated_at\": \"2026-08-10T18:22:32.000000000Z\""), "{json}");
}
#[test]
fn the_schema_constant_is_the_versioning_promise() {
assert_eq!(REPORT_SCHEMA, "fdu.report/7");
assert_eq!(CONTENT_REPORT_SCHEMA, REPORT_SCHEMA);
}
#[test]
fn every_format_states_the_ignore_rules_a_report_could_apply() {
let unobserved = crate::test_support::not_observing_controls();
let mut blind = Index::new_with_scope("/root", unobserved);
blind
.apply(&Observation::new(vec![Op::Upsert {
path: PathBuf::from("a.txt"),
kind: EntryKind::File,
attrs: attrs(1, 1),
}]))
.expect("apply");
let provenance = Provenance {
scan_started_at: None,
generated_at: UNIX_EPOCH,
source: ReportSource::ColdScan,
complete: true,
errors: Vec::new(),
};
let query = Query { views: vec![ViewSpec::Summary], ..Query::default() };
let blind_report =
report(&blind, &crate::test_support::read_of(&blind, query.clone()), &provenance)
.expect("report");
assert!(render(&blind_report, Format::Json, false).contains("\"ignore_rules\": null"));
assert!(render(&blind_report, Format::Yaml, false).contains("\nignore_rules: null\n"));
assert!(blind_report.notes.is_empty());
let mut observed =
Index::new_with_scope("/root", crate::test_support::observing_controls());
let mut long_line = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
long_line.push(b'\n');
observed
.apply(&Observation::new(vec![
Op::Upsert {
path: PathBuf::from("vendor"),
kind: EntryKind::Dir,
attrs: attrs(0, 1),
},
Op::ControlUpsert {
path: PathBuf::from(".gitignore"),
source: b"*.log\n".to_vec(),
},
Op::ControlUpsert { path: PathBuf::from("vendor/.gitignore"), source: long_line },
]))
.expect("apply");
let refused = Path::new("vendor").join(".gitignore");
let refused = refused.to_string_lossy();
let json = render(
&report(
&observed,
&crate::test_support::read_of(&observed, query.clone()),
&provenance,
)
.expect("report"),
Format::Json,
false,
);
let expected = format!(
"\"ignore_rules\": {{\"limits\": {{\"budget\": 4194304, \"line_limit\": 16384}}, \
\"applied\": 1, \"refused\": 1, \"refusals\": [{{\"path\": {}, \"reason\": \
\"line_limit\"}}]}}",
quote(&refused)
);
assert!(compact_json(&json).contains(&compact_json(&expected)), "{json}");
assert!(json.contains("\"complete\": true"), "a refusal is not an operational partial");
let yaml = render(
&report(
&observed,
&crate::test_support::read_of(&observed, query.clone()),
&provenance,
)
.expect("report"),
Format::Yaml,
false,
);
let expected = format!(
"ignore_rules:\n limits: {{budget: 4194304, line_limit: 16384}}\n applied: 1\n \
refused: 1\n refusals:\n -\n path: {}\n reason: line_limit\n",
yaml_scalar(&refused)
);
assert!(yaml.contains(&expected), "{yaml}");
let flags = Query { axes: &crate::query::AxisNames::FLAGS, ..query.clone() };
let note = "note: 1 .gitignore file not applied (1 with a line over the 16 KiB line \
limit), so ignored shares under vendor are not exact; sizes are. To apply \
them, raise --gitignore-line-limit above 16 KiB, or set it to all";
let text = render(
&report(
&observed,
&crate::test_support::read_of(&observed, flags.clone()),
&provenance,
)
.expect("report"),
Format::Text,
false,
);
assert!(text.ends_with(&format!("{note}\n")), "{text}");
let fields =
report(&observed, &crate::test_support::read_of(&observed, query.clone()), &provenance)
.expect("report");
assert_eq!(fields.notes, [note.replace("--gitignore-line-limit", "control_line_limit")]);
}
#[test]
fn every_format_carries_each_rows_ignored_share() {
let build = |scope: ScanScope| {
let mut index = Index::new_with_scope("/root", scope);
let mut ops = vec![
Op::Upsert {
path: PathBuf::from("dist"),
kind: EntryKind::Dir,
attrs: Attrs::default(),
},
Op::Upsert {
path: PathBuf::from("dist/a.gz"),
kind: EntryKind::File,
attrs: attrs(128, 10),
},
Op::Upsert {
path: PathBuf::from("src"),
kind: EntryKind::Dir,
attrs: Attrs::default(),
},
Op::Upsert {
path: PathBuf::from("src/b.rs"),
kind: EntryKind::File,
attrs: attrs(36, 20),
},
];
if scope.observes_controls() {
ops.insert(
0,
Op::ControlUpsert {
path: PathBuf::from(".gitignore"),
source: b"dist/\n".to_vec(),
},
);
}
index.apply(&Observation::new(ops)).expect("apply");
index
};
let provenance = Provenance {
scan_started_at: None,
generated_at: UNIX_EPOCH,
source: ReportSource::ColdScan,
complete: true,
errors: Vec::new(),
};
let views = vec![ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions, ViewSpec::Files];
let query = |ignored| Query {
views: views.clone(),
selection: Selection {
ignored,
size: SizeMetric::Apparent,
limit: Some(Bound::All),
..Selection::default()
},
..Query::default()
};
let observed = build(crate::test_support::observing_controls());
let text = render(
&report(
&observed,
&crate::test_support::read_of(&observed, query(IgnoredEntries::Include)),
&provenance,
)
.expect("report"),
Format::Text,
false,
);
assert_eq!(
text,
concat!(
"SUMMARY\n",
" 164 B 2 files, 2 directories (128 B ignored)\n",
"\n",
"TREE\n",
" 164 B ██████████ 100% . (2 files) (128 B ignored)\n",
" 128 B ████████░░ 78% dist (1 file) (128 B ignored)\n",
" 36 B ██░░░░░░░░ 22% src (1 file)\n",
"\n",
"EXTENSIONS\n",
" 128 B .gz 1 file (128 B ignored)\n",
" 36 B .rs 1 file\n",
"\n",
"FILES\n",
"dist\n",
"dist/a.gz\n",
"src\n",
"src/b.rs\n",
)
.replace('/', std::path::MAIN_SEPARATOR_STR)
);
let dirs_only = IgnoredTally { files: 0, dirs: 1, bytes: 0, allocated: 0 };
assert_eq!(
ignored_suffix(Some(dirs_only), SizeMetric::Apparent, IgnoredEntries::Include),
""
);
let only = render(
&report(
&observed,
&crate::test_support::read_of(&observed, query(IgnoredEntries::Only)),
&provenance,
)
.expect("report"),
Format::Text,
false,
);
assert!(only.contains(" 128 B 1 file, 1 directory\n"), "{only}");
assert!(!only.contains("ignored"), "every row is ignored, so none repeats it: {only}");
let json = render(
&report(
&observed,
&crate::test_support::read_of(&observed, query(IgnoredEntries::Include)),
&provenance,
)
.expect("report"),
Format::Json,
false,
);
assert!(is_valid_json(&json), "{json}");
let compact = compact_json(&json);
for expected in [
"\"summary\": {\"files\": 2, \"dirs\": 2, \"bytes\": 164, \"allocated\": 1024, \
\"ignored\": {\"files\": 1, \"dirs\": 1, \"bytes\": 128, \"allocated\": 512}, ",
"\"name\": \"src\", \"path\": \"src\", \"kind\": \"dir\", \"bytes\": 36, \
\"allocated\": 512, \"files\": 1, \"dirs\": 0, \"ignored\": {\"files\": 0, \
\"dirs\": 0, \"bytes\": 0, \"allocated\": 0}, ",
"{\"extension\": \".gz\", \"files\": 1, \"bytes\": 128, \"allocated\": 512, \
\"ignored\": {\"files\": 1, \"bytes\": 128, \"allocated\": 512}}",
"\"kind\": \"dir\", \"bytes\": 128, \"allocated\": 512, \"mtime_ns\": 10, \"files\": 1, \"dirs\": 0, \"complete\": true, \"age_ns\": -10, \"ignored\": true}",
] {
assert!(compact.contains(&compact_json(expected)), "missing {expected}\nin {json}");
}
let yaml = render(
&report(
&observed,
&crate::test_support::read_of(&observed, query(IgnoredEntries::Include)),
&provenance,
)
.expect("report"),
Format::Yaml,
false,
);
assert!(
yaml.contains(
" allocated: 1024\n ignored: {files: 1, dirs: 1, bytes: 128, \
allocated: 512}\n newest_mtime_ns: 20\n"
),
"{yaml}"
);
assert!(yaml.contains(" ignored: true\n"), "{yaml}");
let blind = build(crate::test_support::not_observing_controls());
let blind_report = report(
&blind,
&crate::test_support::read_of(&blind, query(IgnoredEntries::Include)),
&provenance,
)
.expect("report");
assert!(!render(&blind_report, Format::Text, false).contains("ignored"));
let json = render(&blind_report, Format::Json, false);
assert!(!json.contains("\"ignored\": {"), "never a zero share for an unread rule: {json}");
assert!(json.contains("\"ignored\": null"), "{json}");
assert!(render(&blind_report, Format::Yaml, false).contains("ignored: null\n"));
}
#[test]
fn every_report_uses_one_schema_and_states_nullable_analysis() {
let metadata = render(&fixture(&[ViewSpec::Tree]), Format::Json, false);
assert!(metadata.contains("\"schema\": \"fdu.report/7\""));
assert!(metadata.contains("\"analysis\": null"));
let metrics = render(&fixture(&[ViewSpec::Types]), Format::Json, false);
assert!(metrics.contains("\"schema\": \"fdu.report/7\""));
assert!(metrics.contains("\"analysis\": null"));
assert!(metrics.contains("\"share\": {\"numerator\":"));
}
#[cfg(feature = "watch")]
#[test]
fn a_stream_record_is_pinned_field_by_field() {
use crate::{Change, ChangeKind};
assert_eq!(STREAM_SCHEMA, "fdu.stream/2");
let upsert = Change {
path: ["src", "main.rs"].iter().collect(),
kind: ChangeKind::Upsert,
entry_kind: Some(EntryKind::File),
bytes: Some(2_048),
allocated: Some(4_096),
mtime_ns: Some(1_700_000_000_000_000_000),
ignored: Some(false),
clock: 7,
};
let path = upsert.path.to_string_lossy().replace('\\', "\\\\");
assert_eq!(
render_change(&upsert, Format::Json),
format!(
"{{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"upsert\", \
\"path\": \"{path}\", \"clock\": 7, \"kind\": \"file\", \"bytes\": 2048, \
\"allocated\": 4096, \"mtime_ns\": 1700000000000000000, \"ignored\": false}}"
)
);
let unclassified = Change { ignored: None, ..upsert.clone() };
assert_eq!(
render_change(&unclassified, Format::Json),
format!(
"{{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"upsert\", \
\"path\": \"{path}\", \"clock\": 7, \"kind\": \"file\", \"bytes\": 2048, \
\"allocated\": 4096, \"mtime_ns\": 1700000000000000000}}"
)
);
let removed = Change {
path: PathBuf::from("gone.txt"),
kind: ChangeKind::Remove,
entry_kind: None,
bytes: None,
allocated: None,
mtime_ns: None,
ignored: None,
clock: 8,
};
assert_eq!(
render_change(&removed, Format::Json),
"{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"remove\", \
\"path\": \"gone.txt\", \"clock\": 8}"
);
let reclassified =
Change { path: PathBuf::from("debug.log"), ignored: Some(true), ..removed.clone() };
assert_eq!(
render_change(&reclassified, Format::Json),
"{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"remove\", \
\"path\": \"debug.log\", \"clock\": 8, \"ignored\": true}"
);
let invalidated = Change {
path: PathBuf::from("subtree"),
kind: ChangeKind::Invalidate,
entry_kind: None,
bytes: None,
allocated: None,
mtime_ns: None,
ignored: None,
clock: 9,
};
assert_eq!(
render_change(&invalidated, Format::Json),
"{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"invalidate\", \
\"path\": \"subtree\", \"clock\": 9}"
);
assert_eq!(render_change(&removed, Format::Text), "gone.txt\tremove");
}
#[test]
fn a_files_view_prints_one_path_per_line_and_nothing_else() {
let text = render(&fixture(&[ViewSpec::Files]), Format::Text, false);
for line in text.lines() {
assert!(!line.contains(' '), "text files output must be bare paths, got {line:?}");
}
let expected: PathBuf = ["src", "main.rs"].iter().collect();
let expected = expected.display().to_string();
assert!(text.lines().any(|line| line == expected), "{text}");
}
#[test]
fn several_views_are_labelled_and_a_lone_view_is_left_bare() {
let text = render(
&fixture(&[ViewSpec::Tree, ViewSpec::Types, ViewSpec::Summary]),
Format::Text,
false,
);
let headers: Vec<&str> = text.lines().filter(|line| is_view_header_line(line)).collect();
assert_eq!(headers, ["TREE", "TYPES", "SUMMARY"], "{text}");
let lines: Vec<&str> = text.lines().collect();
for (index, line) in lines.iter().enumerate() {
if headers.contains(line) {
assert!(
lines.get(index + 1).is_some_and(|next| !next.is_empty()),
"header {line} must sit directly above its rows:\n{text}"
);
if index > 0 {
assert!(
lines[index - 1].is_empty(),
"a blank line must precede header {line}:\n{text}"
);
}
}
}
for view in [ViewSpec::Tree, ViewSpec::Types, ViewSpec::Summary] {
let lone = render(&fixture(&[view]), Format::Text, false);
assert!(
!lone.lines().any(is_view_header_line),
"{view:?} alone must not be labelled:\n{lone}"
);
}
}
#[test]
fn view_headers_are_colorized_only_when_color_is_on() {
let views = [ViewSpec::Tree, ViewSpec::Summary];
let plain = render(&fixture(&views), Format::Text, false);
assert!(plain.starts_with("TREE\n"), "{plain}");
assert!(!plain.contains('\u{1b}'), "uncolored text carries no escapes: {plain:?}");
let colored = render(&fixture(&views), Format::Text, true);
assert!(colored.contains(&paint("TREE", STYLE_VIEW_HEADER, true)), "{colored:?}");
assert!(colored.contains(&paint("SUMMARY", STYLE_VIEW_HEADER, true)), "{colored:?}");
}
#[test]
fn no_machine_format_gains_a_text_header() {
let views = [ViewSpec::Tree, ViewSpec::Types, ViewSpec::Files, ViewSpec::Summary];
for format in [Format::Json, Format::Jsonl, Format::Yaml] {
let rendered = render(&fixture(&views), format, false);
for header in ["TREE", "TYPES", "FILES", "SUMMARY"] {
assert!(!rendered.contains(header), "{format:?} leaked {header}:\n{rendered}");
}
}
}
#[test]
fn every_view_has_a_header_that_matches_its_wire_label() {
for view in [
ViewSpec::Tree,
ViewSpec::Extensions,
ViewSpec::Types,
ViewSpec::Families,
ViewSpec::Languages,
ViewSpec::Documents,
ViewSpec::Files,
ViewSpec::Summary,
] {
let header = view_header(view);
assert_eq!(header, view.label().to_uppercase(), "{view:?}");
assert!(
!header.is_empty() && header.chars().all(|c| c.is_ascii_uppercase()),
"{view:?}"
);
}
}
#[test]
fn yaml_quotes_only_what_would_be_ambiguous() {
assert_eq!(yaml_scalar("cold_scan"), "cold_scan");
assert_eq!(yaml_scalar("src/main.rs"), "src/main.rs");
assert_eq!(yaml_scalar("true"), "\"true\"");
assert_eq!(yaml_scalar("null"), "\"null\"");
assert_eq!(yaml_scalar("12345"), "\"12345\"");
assert_eq!(yaml_scalar(""), "\"\"");
assert_eq!(yaml_scalar("has space"), "\"has space\"");
assert_eq!(yaml_scalar("-leading-dash"), "\"-leading-dash\"");
}
#[test]
fn json_strings_escape_control_characters_and_quotes() {
assert_eq!(quote("a\"b"), "\"a\\\"b\"");
assert_eq!(quote("a\\b"), "\"a\\\\b\"");
assert_eq!(quote("a\nb"), "\"a\\nb\"");
assert_eq!(quote("a\u{1}b"), "\"a\\u0001b\"");
}
#[test]
fn format_values_parse_and_reject_by_name() {
assert_eq!(Format::parse("json"), Some(Format::Json));
assert_eq!(Format::parse(" YAML "), Some(Format::Yaml));
assert_eq!(Format::parse("xml"), None);
assert_eq!(Format::ALL.len(), 7);
}
#[test]
fn human_bytes_reads_at_scale() {
assert_eq!(human_bytes(0), "0 B");
assert_eq!(human_bytes(512), "512 B");
assert_eq!(human_bytes(1024), "1.0 KiB");
assert_eq!(human_bytes(1024 * 1024 * 20), "20 MiB");
}
#[test]
fn bars_are_fixed_at_ten_cells_and_saturate() {
assert_eq!(bar(0.0, false), "â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘");
assert_eq!(bar(0.5, false), "█████░░░░░");
assert_eq!(bar(2.0, false), "██████████");
assert!((ratio(5, 0) - 0.0).abs() < f64::EPSILON);
}
const DEEP_RENDER_CHILD_ENV: &str = "FDU_DEEP_RENDER_CHILD";
const DEEP_RENDER_DEPTH: usize = 1_024;
const DEEP_RENDER_STACK_BYTES: usize = 64 * 1_024;
#[test]
fn deep_rendering_is_stack_safe() {
if std::env::var_os(DEEP_RENDER_CHILD_ENV).is_some() {
run_deep_render_child();
return;
}
let output = Command::new(std::env::current_exe().expect("current test executable"))
.args(["--exact", DEEP_RENDER_TEST_PATH, "--nocapture"])
.env(DEEP_RENDER_CHILD_ENV, "1")
.output()
.expect("run deep-render child");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"deep renderer failed in child process\nstdout:\n{stdout}\nstderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
stdout.contains("1 passed"),
"the child must actually run the deep render, not filter it away\nstdout:\n{stdout}"
);
}
const DEEP_RENDER_TEST_PATH: &str = "report_format::tests::deep_rendering_is_stack_safe";
fn run_deep_render_child() {
let mut index = crate::Index::new("/fixture");
let mut path = PathBuf::new();
for depth in 0..DEEP_RENDER_DEPTH {
path.push("d");
index.apply_ok(&crate::Observation::new(vec![crate::Op::Upsert {
path: path.clone(),
kind: EntryKind::Dir,
attrs: crate::Attrs {
mtime_ns: i64::try_from(depth).expect("fixture depth fits i64"),
..Default::default()
},
}]));
}
index.set_initial_freshness(false);
std::thread::Builder::new()
.name("deep-render".to_string())
.stack_size(DEEP_RENDER_STACK_BYTES)
.spawn(move || {
let query = Query {
selection: Selection { depth: Some(Bound::All), ..Selection::default() },
views: vec![ViewSpec::Tree],
..Query::default()
};
let provenance = Provenance {
scan_started_at: None,
generated_at: SystemTime::UNIX_EPOCH,
source: ReportSource::ColdScan,
complete: true,
errors: Vec::new(),
};
let report = report(
&index,
&crate::test_support::read_of(&index, query.clone()),
&provenance,
)
.expect("report");
for format in [Format::Text, Format::Json, Format::Jsonl, Format::Yaml] {
let rendered = render(&report, format, false);
assert!(!rendered.is_empty(), "{format:?} rendered nothing for a deep tree");
if format != Format::Text {
let mut streamed = Vec::new();
write(&report, format, false, &mut streamed).expect("stream deep report");
assert_eq!(streamed, rendered.as_bytes(), "{format:?} bytes differ");
}
}
})
.expect("spawn deep-render thread")
.join()
.expect("deep-render thread");
}
fn assert_json_preserves_raw_identity(
root: PathBuf,
first: &OsStr,
second: &OsStr,
encoding: &str,
root_hex: &str,
first_hex: &str,
second_hex: &str,
) {
assert_eq!(first.to_string_lossy(), second.to_string_lossy());
let mut index = crate::Index::new(root);
index.apply_ok(&crate::Observation::new(vec![
crate::Op::Upsert {
path: PathBuf::from(first),
kind: EntryKind::File,
attrs: crate::Attrs { size: 1, allocated: 1, ..Default::default() },
},
crate::Op::Upsert {
path: PathBuf::from(second),
kind: EntryKind::File,
attrs: crate::Attrs { size: 1, allocated: 1, ..Default::default() },
},
]));
index.set_initial_freshness(false);
let query = crate::query::Query {
views: vec![ViewSpec::Files],
selection: Selection {
depth: Some(crate::query::Bound::All),
limit: Some(crate::query::Bound::All),
..Selection::default()
},
..crate::query::Query::default()
};
let provenance = Provenance {
scan_started_at: None,
generated_at: std::time::UNIX_EPOCH,
source: ReportSource::ColdScan,
complete: true,
errors: Vec::new(),
};
let files_report =
report(&index, &crate::test_support::read_of(&index, query.clone()), &provenance)
.expect("report");
let rendered = render(&files_report, Format::Json, false);
let mut checked = SchemaCheck::report(JsonSink::pretty(), true, true);
emit_report(&mut checked, &files_report, true);
assert_eq!(checked.finish(), rendered);
let lossy = first.to_string_lossy();
assert_eq!(
rendered.matches(&format!("\"{lossy}\"")).count(),
2,
"both names render the same lossy text: {rendered}"
);
assert!(
rendered.contains(&format!(
"\"root_raw\": {{\"encoding\": \"{encoding}\", \"hex\": \"{root_hex}\"}}"
)),
"{rendered}"
);
for hex in [first_hex, second_hex] {
let row = format!(
"{{\"path\": \"{lossy}\", \"path_raw\": {{\"encoding\": \"{encoding}\", \"hex\": \"{hex}\"}}, \
\"kind\": \"file\", \"bytes\": 1, \"allocated\": 1, \"mtime_ns\": 0, \
\"files\": null, \"dirs\": null, \"complete\": null, \"age_ns\": 0, \"ignored\": false}}"
);
assert!(
compact_json(&rendered).contains(&compact_json(&row)),
"a name that is not valid Unicode must carry its raw bytes in a well-formed \
row.\nexpected: {row}\nrendered: {rendered}"
);
}
assert!(
!rendered.contains(", ,") && !rendered.contains(",,"),
"duplicated separator in machine output: {rendered}"
);
let mut dirs = crate::Index::new(PathBuf::from("/tree-fixture"));
dirs.apply_ok(&crate::Observation::new(vec![
crate::Op::Upsert {
path: PathBuf::from(first),
kind: EntryKind::Dir,
attrs: crate::Attrs { size: 0, allocated: 0, ..Default::default() },
},
crate::Op::Upsert {
path: PathBuf::from(first).join("inside.txt"),
kind: EntryKind::File,
attrs: crate::Attrs { size: 1, allocated: 1, ..Default::default() },
},
]));
dirs.set_initial_freshness(false);
let tree_query = crate::query::Query {
views: vec![ViewSpec::Tree],
selection: Selection {
depth: Some(crate::query::Bound::All),
limit: Some(crate::query::Bound::All),
..Selection::default()
},
..crate::query::Query::default()
};
let tree =
report(&dirs, &crate::test_support::read_of(&dirs, tree_query.clone()), &provenance)
.expect("report");
let tree_rendered = render(&tree, Format::Json, false);
assert!(
compact_json(&tree_rendered).contains(&compact_json(&format!(
", \"path_raw\": {{\"encoding\": \"{encoding}\", \"hex\": \"{first_hex}\"}}, \"kind\":"
))),
"the tree view must carry raw identity in a well-formed node: {tree_rendered}"
);
assert!(
!tree_rendered.contains(", ,") && !tree_rendered.contains(",,"),
"duplicated separator in tree output: {tree_rendered}"
);
}
#[cfg(unix)]
#[test]
fn json_preserves_distinct_non_unicode_unix_names() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
assert_json_preserves_raw_identity(
PathBuf::from(OsString::from_vec(vec![b'/', 0x80])),
&OsString::from_vec(vec![b'n', 0x80]),
&OsString::from_vec(vec![b'n', 0x81]),
"unix-bytes",
"2f80",
"6e80",
"6e81",
);
}
#[cfg(windows)]
#[test]
fn json_preserves_distinct_non_unicode_windows_names() {
use std::ffi::OsString;
use std::os::windows::ffi::OsStringExt;
assert_json_preserves_raw_identity(
PathBuf::from(OsString::from_wide(&[u16::from(b'R'), u16::from(b':'), 0xd800])),
&OsString::from_wide(&[u16::from(b'n'), 0xd800]),
&OsString::from_wide(&[u16::from(b'n'), 0xd801]),
"windows-wtf16le",
"52003a0000d8",
"6e0000d8",
"6e0001d8",
);
}
}