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