1mod report_epilogue;
57
58pub use report_epilogue::{
59 DiagnosticLines, diagnostic_lines, diagnostics, report_notes, report_tips, report_warnings,
60};
61
62use std::fmt::Write as _;
63use std::io;
64use std::path::Path;
65
66use anstyle::{AnsiColor, Style as AnsiStyle};
67
68use crate::classify::human_language_name;
69use crate::content::{CoverageReason, METRICS};
70use crate::control::ControlCoverage;
71use crate::emit::{Event, IoFmt, JsonSink, Scalar, Shape, Sink, YamlSink};
72use crate::engine_contract::{Coverage, EntryKind, Freshness, IssueKind, Source};
73use crate::query::{
74 CodeOverview, CodeTally, FileRow, IgnoredEntries, IgnoredTally, MetricGroup, MetricRow,
75 MetricSummary, Report, ReportSource, Section, ShareMetric, SizeMetric, SummaryRow, TierState,
76 TreeNode, TypeRow, ViewSpec, format_rfc3339, format_rfc3339_nanos, pages,
77};
78
79pub const STYLE_HEADING: AnsiStyle = AnsiColor::Cyan.on_default().bold();
85
86pub const STYLE_NAME: AnsiStyle = AnsiColor::BrightCyan.on_default().bold();
88
89const STYLE_IGNORED_NAME: AnsiStyle = AnsiColor::Cyan.on_default();
91
92const STYLE_BAR: AnsiStyle = AnsiColor::Green.on_default();
93
94pub const STYLE_CATEGORY: AnsiStyle = AnsiColor::Cyan.on_default();
96
97pub const STYLE_DETAIL: AnsiStyle = AnsiColor::BrightBlack.on_default();
100
101const TEXT_METRIC_LABEL_WIDTH: usize = 18;
103const TEXT_TYPE_LABEL_WIDTH: usize = 12;
105
106pub const REPORT_SCHEMA: &str = "fdu.report/10";
111pub const CONTENT_REPORT_SCHEMA: &str = REPORT_SCHEMA;
113pub const CACHE_SCHEMA: &str = "fdu.cache/3";
124
125#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
127pub enum Format {
128 #[default]
130 Text,
131 Tree,
133 Paths,
135 Long,
137 Json,
139 Jsonl,
141 Yaml,
143}
144
145pub const MAX_BAR_SIZE: usize = 4096;
148
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub struct RenderOptions {
152 pub color: bool,
154 pub bar_size: usize,
156}
157
158impl Default for RenderOptions {
159 fn default() -> Self {
160 Self { color: false, bar_size: 10 }
161 }
162}
163
164pub const fn document_start(format: Format) -> &'static str {
166 match format {
167 Format::Yaml => "---\n",
168 Format::Text
169 | Format::Tree
170 | Format::Paths
171 | Format::Long
172 | Format::Json
173 | Format::Jsonl => "",
174 }
175}
176
177impl Format {
178 pub const fn label(self) -> &'static str {
180 match self {
181 Self::Text => "text",
182 Self::Tree => "tree",
183 Self::Paths => "paths",
184 Self::Long => "long",
185 Self::Json => "json",
186 Self::Jsonl => "jsonl",
187 Self::Yaml => "yaml",
188 }
189 }
190
191 pub const fn is_machine(self) -> bool {
193 matches!(self, Self::Json | Self::Jsonl | Self::Yaml)
194 }
195
196 pub fn parse(value: &str) -> Option<Self> {
198 match value.trim().to_ascii_lowercase().as_str() {
199 "text" => Some(Self::Text),
200 "tree" => Some(Self::Tree),
201 "paths" => Some(Self::Paths),
202 "long" => Some(Self::Long),
203 "json" => Some(Self::Json),
204 "jsonl" => Some(Self::Jsonl),
205 "yaml" => Some(Self::Yaml),
206 _ => None,
207 }
208 }
209
210 pub const ALL: &'static [&'static str] =
212 &["text", "tree", "paths", "long", "json", "jsonl", "yaml"];
213}
214
215pub fn render(report: &Report, format: Format, color: bool) -> crate::Result<String> {
226 render_with_options(report, format, RenderOptions { color, ..RenderOptions::default() })
227}
228
229pub fn render_with_options(
238 report: &Report,
239 format: Format,
240 options: RenderOptions,
241) -> crate::Result<String> {
242 let format = checked_format(report, format)?;
243 checked_bar_size(report, format, options)?;
244 Ok(match format {
245 Format::Text | Format::Tree => render_text(report, options),
246 Format::Paths | Format::Long => render_flat(report, format),
247 Format::Json => render_report_machine(report, true, JsonSink::pretty()),
248 Format::Jsonl => render_report_jsonl(report),
249 Format::Yaml => render_report_machine(report, true, YamlSink::new()),
250 })
251}
252
253fn flat_path(path: &Path) -> String {
263 path.to_string_lossy()
264 .chars()
265 .flat_map(|c| if c.is_control() { c.escape_default().collect::<Vec<_>>() } else { vec![c] })
266 .collect()
267}
268
269fn human_age(age: Option<i128>) -> String {
271 let Some(age) = age else { return "unknown".to_string() };
272 let seconds = age.unsigned_abs() / 1_000_000_000;
273 let (amount, unit) = if seconds >= 86400 {
274 (seconds / 86400, "d")
275 } else if seconds >= 3600 {
276 (seconds / 3600, "h")
277 } else if seconds >= 60 {
278 (seconds / 60, "m")
279 } else {
280 (seconds, "s")
281 };
282 format!("{}{}{unit}", if age < 0 { "-" } else { "" }, human_count_u128(amount))
283}
284
285pub fn flat_diagnostics(report: &Report) -> Vec<String> {
288 report_epilogue::with_warnings(report, flat_diagnostic_lines(report))
289}
290
291pub fn flat_diagnostic_lines(report: &Report) -> DiagnosticLines {
298 let DiagnosticLines { mut notes, tips } = diagnostic_lines(report);
299 if !report.status.complete || report.provenance.freshness != Freshness::Fresh {
300 notes.push(format!(
301 "note: result {}, {}",
302 freshness_label(report.provenance.freshness),
303 if report.status.complete { "complete" } else { "incomplete" }
304 ));
305 }
306 if let Some(depth) = report.scope.max_depth {
307 notes.push(format!(
308 "note: scanned to depth {} only; totals cover that scope",
309 human_count_u128(depth as u128)
310 ));
311 }
312 DiagnosticLines { notes, tips }
313}
314
315fn render_flat(report: &Report, format: Format) -> String {
316 let mut out = String::new();
317 for section in &report.sections {
318 if let Section::Files { rows, .. } = section {
319 for row in rows {
320 if format == Format::Long {
321 let _ = writeln!(
322 out,
323 "{:>10} {:>8} {}",
324 human_bytes(pick(report.size, row.bytes, row.allocated)),
325 human_age(row.age_ns),
326 flat_path(&row.path)
327 );
328 } else {
329 let _ = writeln!(out, "{}", flat_path(&row.path));
330 }
331 }
332 }
333 }
334 out
335}
336
337fn checked_format(report: &Report, format: Format) -> crate::Result<Format> {
338 let format = if format == Format::Text { report.format } else { format };
339 let valid = match format {
340 Format::Paths | Format::Long => {
341 report.sections.len() == 1 && matches!(report.sections[0], Section::Files { .. })
342 }
343 Format::Tree => {
344 report.sections.len() == 1 && matches!(report.sections[0], Section::Tree { .. })
345 }
346 Format::Text | Format::Json | Format::Jsonl | Format::Yaml => true,
347 };
348 if !valid {
349 return Err(crate::Error::InvalidRequest(crate::query::Rejection::new(format.label(),
350 "incompatible with this report projection; request the desired format when building the query (a folded tree cannot become a complete flat list)").on("format")));
351 }
352 Ok(format)
353}
354
355fn checked_bar_size(report: &Report, format: Format, options: RenderOptions) -> crate::Result<()> {
356 if matches!(format, Format::Text | Format::Tree)
357 && report.sections.iter().any(|section| matches!(section, Section::Tree { .. }))
358 && options.bar_size > MAX_BAR_SIZE
359 {
360 return Err(crate::Error::InvalidRequest(
361 crate::query::Rejection::new(
362 options.bar_size.to_string(),
363 format!("at most {MAX_BAR_SIZE} cells"),
364 )
365 .on("bar_size"),
366 ));
367 }
368 Ok(())
369}
370
371pub fn write(
376 report: &Report,
377 format: Format,
378 color: bool,
379 out: &mut dyn io::Write,
380) -> io::Result<()> {
381 write_with_options(report, format, RenderOptions { color, ..RenderOptions::default() }, out)
382}
383
384pub fn write_with_options(
393 report: &Report,
394 format: Format,
395 options: RenderOptions,
396 out: &mut dyn io::Write,
397) -> io::Result<()> {
398 let format = checked_format(report, format)
399 .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
400 checked_bar_size(report, format, options)
401 .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
402 match format {
403 Format::Text | Format::Tree => out.write_all(render_text(report, options).as_bytes()),
404 Format::Paths | Format::Long => out.write_all(render_flat(report, format).as_bytes()),
405 Format::Json => write_report_machine(report, true, JsonSink::pretty_to(out)),
406 Format::Jsonl => write_report_jsonl(report, out),
407 Format::Yaml => write_report_machine(report, true, YamlSink::to(out)),
408 }
409}
410
411fn render_report_machine(
412 report: &Report,
413 with_sections: bool,
414 mut sink: impl Sink<Output = String>,
415) -> String {
416 emit_report(&mut sink, report, with_sections);
417 sink.finish()
418}
419
420fn write_report_machine<'a>(
421 report: &Report,
422 with_sections: bool,
423 mut sink: impl Sink<Output = IoFmt<'a>>,
424) -> io::Result<()> {
425 emit_report(&mut sink, report, with_sections);
426 sink.finish().finish()
427}
428
429fn render_report_jsonl(report: &Report) -> String {
430 let mut sink = JsonSink::line();
431 emit_report(&mut sink, report, false);
432 let mut out = sink.finish();
433 out.push('\n');
434 for section in &report.sections {
435 let mut sink = JsonSink::line();
436 emit_section(&mut sink, section);
437 out.push_str(&sink.finish());
438 out.push('\n');
439 }
440 out
441}
442
443fn write_report_jsonl(report: &Report, out: &mut dyn io::Write) -> io::Result<()> {
444 let mut sink = JsonSink::line_to(out);
445 emit_report(&mut sink, report, false);
446 sink.finish().finish()?;
447 out.write_all(b"\n")?;
448 for section in &report.sections {
449 let mut sink = JsonSink::line_to(out);
450 emit_section(&mut sink, section);
451 sink.finish().finish()?;
452 out.write_all(b"\n")?;
453 }
454 Ok(())
455}
456
457fn emit_field<S: Sink>(sink: &mut S, field: Field, condition: bool, emit: impl FnOnce(&mut S)) {
458 let present = match field.presence {
459 Presence::Always | Presence::Nullable => true,
460 Presence::WhenAnalyzer(_) => {
461 panic!("an analyzer-owned field must use emit_analyzer_field")
462 }
463 Presence::WhenLossy | Presence::WhenSet => condition,
464 };
465 if present {
466 sink.event(Event::Key(field.name));
467 emit(sink);
468 }
469}
470
471fn emit_analyzer_field<S: Sink>(
472 sink: &mut S,
473 requested: crate::content::AnalysisSet,
474 field: Field,
475 emit: impl FnOnce(&mut S),
476) {
477 let Presence::WhenAnalyzer(owner) = field.presence else {
478 panic!("an analyzer field must declare its owning unit");
479 };
480 if requested.contains(owner) {
481 sink.event(Event::Key(field.name));
482 emit(sink);
483 }
484}
485
486fn emit_scalar(sink: &mut impl Sink, value: Scalar<'_>) {
487 sink.event(Event::Scalar(value));
488}
489
490fn emit_report(sink: &mut impl Sink, report: &Report, with_sections: bool) {
491 sink.event(Event::BeginMap(Shape::Block));
492 emit_field(sink, REPORT_FIELDS.schema, true, |sink| {
493 emit_scalar(sink, Scalar::Str(REPORT_SCHEMA));
494 });
495 let generator = generator();
496 emit_field(sink, REPORT_FIELDS.generator, true, |sink| {
497 emit_scalar(sink, Scalar::Str(&generator));
498 });
499 let root = report.root.to_string_lossy();
500 emit_field(sink, REPORT_FIELDS.root, true, |sink| {
501 emit_scalar(sink, Scalar::Str(&root));
502 });
503 emit_raw_identity(sink, REPORT_FIELDS.root_raw.name, &report.root);
504 emit_field(sink, REPORT_FIELDS.age_reference_ns, true, |sink| match report.age_reference_ns {
505 Some(value) => emit_scalar(sink, Scalar::I64(value)),
506 None => emit_scalar(sink, Scalar::Null),
507 });
508 emit_field(sink, REPORT_FIELDS.request, true, |sink| emit_request(sink, report));
509 emit_field(sink, REPORT_FIELDS.status, true, |sink| emit_status(sink, report));
510 emit_field(sink, REPORT_FIELDS.provenance, true, |sink| emit_provenance(sink, report));
511 emit_field(sink, REPORT_FIELDS.ignore_rules, true, |sink| {
512 emit_ignore_rules(sink, &report.ignore_rules);
513 });
514 emit_field(sink, REPORT_FIELDS.analysis, true, |sink| {
515 emit_analysis(sink, report.analysis.as_ref());
516 });
517 emit_field(sink, REPORT_FIELDS.reports, with_sections, |sink| {
518 sink.event(Event::BeginSeq(Shape::Block));
519 for section in &report.sections {
520 emit_section(sink, section);
521 }
522 sink.event(Event::EndSeq);
523 });
524 sink.event(Event::EndMap);
525}
526
527fn emit_request(sink: &mut impl Sink, report: &Report) {
528 sink.event(Event::BeginMap(Shape::Block));
529 emit_field(sink, Field::always("scope"), true, |sink| {
530 sink.event(Event::BeginMap(Shape::Block));
531 emit_field(sink, Field::nullable("max_depth"), true, |sink| {
532 emit_optional_usize(sink, report.scope.max_depth);
533 });
534 emit_bool_field(sink, "follow_symlinks", report.scope.follow_symlinks);
535 emit_bool_field(sink, "one_filesystem", report.scope.one_filesystem);
536 emit_bool_field(sink, "exclude_special", report.scope.exclude_special);
537 emit_bool_field(sink, "read_controls", report.scope.observes_controls());
538 emit_str_field(sink, "population", report.scope.population.label());
539 sink.event(Event::EndMap);
540 });
541 emit_field(sink, Field::always("analyze"), true, |sink| {
542 sink.event(Event::BeginSeq(Shape::Inline));
543 for label in analysis_set_labels(report.requested_analysis) {
544 emit_scalar(sink, Scalar::Str(label));
545 }
546 sink.event(Event::EndSeq);
547 });
548 emit_str_field(sink, "size", report.size.label());
549 emit_field(sink, Field::nullable("sort_metric"), true, |sink| match report.sort_metric {
550 Some(name) => emit_scalar(sink, Scalar::Str(name)),
551 None => emit_scalar(sink, Scalar::Null),
552 });
553 emit_field(sink, Field::always("views"), true, |sink| {
554 sink.event(Event::BeginSeq(Shape::Inline));
555 for view in &report.requested_views {
556 emit_scalar(sink, Scalar::Str(view.label()));
557 }
558 sink.event(Event::EndSeq);
559 });
560 emit_field(sink, Field::always("omitted_views"), true, |sink| {
561 sink.event(Event::BeginSeq(Shape::Inline));
562 for view in &report.omitted_views {
563 emit_scalar(sink, Scalar::Str(view.label()));
564 }
565 sink.event(Event::EndSeq);
566 });
567 sink.event(Event::EndMap);
568}
569
570fn emit_status(sink: &mut impl Sink, report: &Report) {
571 sink.event(Event::BeginMap(Shape::Block));
572 emit_bool_field(sink, "complete", report.status.complete);
573 emit_field(sink, Field::always("coverage"), true, |sink| {
574 sink.event(Event::BeginMap(Shape::Inline));
575 match report.status.coverage {
576 Coverage::Complete => emit_str_field(sink, "kind", "complete"),
577 Coverage::Partial(reason) => {
578 emit_str_field(sink, "kind", "partial");
579 emit_str_field(sink, "reason", structural_coverage_label(reason));
580 }
581 }
582 sink.event(Event::EndMap);
583 });
584 emit_field(sink, Field::always("errors"), true, |sink| {
585 sink.event(Event::BeginSeq(Shape::Block));
586 for error in &report.status.errors {
587 sink.event(Event::BeginMap(Shape::Block));
588 emit_field(sink, Field::when_set("path"), error.path.is_some(), |sink| {
589 let path = error.path.as_ref().expect("present error path");
590 let display = path.to_string_lossy();
591 emit_scalar(sink, Scalar::Str(&display));
592 });
593 if let Some(path) = &error.path {
594 emit_raw_identity(sink, "path_raw", path);
595 }
596 emit_str_field(sink, "kind", issue_kind_label(error.kind));
597 emit_str_field(sink, "message", &error.message);
598 emit_field(sink, Field::when_set("os_error"), error.os_error.is_some(), |sink| {
599 emit_scalar(sink, Scalar::I64(i64::from(error.os_error.expect("present errno"))));
600 });
601 sink.event(Event::EndMap);
602 }
603 sink.event(Event::EndSeq);
604 });
605 emit_u64_field(sink, "errors_omitted", report.status.errors_omitted);
606 sink.event(Event::EndMap);
607}
608
609fn emit_provenance(sink: &mut impl Sink, report: &Report) {
610 sink.event(Event::BeginMap(Shape::Block));
611 emit_str_field(sink, "source", source_label(report.provenance.source));
612 emit_str_field(sink, "freshness", freshness_label(report.provenance.freshness));
613 emit_field(sink, Field::nullable("scan_started_at"), true, |sink| {
614 if let Some(at) = report.provenance.scan_started_at {
615 let value = format_rfc3339(at);
616 emit_scalar(sink, Scalar::Str(&value));
617 } else {
618 emit_scalar(sink, Scalar::Null);
619 }
620 });
621 let generated_at = format_rfc3339(report.provenance.generated_at);
622 emit_str_field(sink, "generated_at", &generated_at);
623 emit_field(sink, Field::always("tiers"), true, |sink| {
624 sink.event(Event::BeginMap(Shape::Block));
625 emit_field(sink, Field::always("entries"), true, |sink| {
626 emit_tier_state(sink, report.provenance.tiers.entries);
627 });
628 emit_field(sink, Field::nullable("content"), true, |sink| {
629 if let Some(content) = report.provenance.tiers.content {
630 emit_tier_state(sink, content);
631 } else {
632 emit_scalar(sink, Scalar::Null);
633 }
634 });
635 sink.event(Event::EndMap);
636 });
637 sink.event(Event::EndMap);
638}
639
640fn emit_tier_state(sink: &mut impl Sink, tier: TierState) {
641 sink.event(Event::BeginMap(Shape::Inline));
642 emit_str_field(sink, "source", tier_source_label(tier.source));
643 emit_str_field(sink, "freshness", freshness_label(tier.freshness));
644 emit_field(sink, Field::nullable("observed_at_ns"), true, |sink| match tier.observed_at_ns {
645 Some(value) => emit_scalar(sink, Scalar::I64(value)),
646 None => emit_scalar(sink, Scalar::Null),
647 });
648 sink.event(Event::EndMap);
649}
650
651fn emit_ignore_rules(sink: &mut impl Sink, rules: &ControlCoverage) {
652 let ControlCoverage::Observed(observed) = rules else {
653 emit_scalar(sink, Scalar::Null);
654 return;
655 };
656 sink.event(Event::BeginMap(Shape::Block));
657 emit_field(sink, Field::always("limits"), true, |sink| {
658 sink.event(Event::BeginMap(Shape::Inline));
659 emit_field(sink, Field::nullable("budget"), true, |sink| {
660 emit_optional_usize(sink, observed.limits.budget);
661 });
662 emit_field(sink, Field::nullable("line_limit"), true, |sink| {
663 emit_optional_usize(sink, observed.limits.line_limit);
664 });
665 sink.event(Event::EndMap);
666 });
667 emit_u64_field(sink, "applied", observed.applied);
668 emit_u64_field(sink, "rules", observed.rules);
669 emit_u64_field(sink, "refused", observed.refused);
670 emit_field(sink, Field::always("refusals"), true, |sink| {
671 sink.event(Event::BeginSeq(Shape::Block));
672 for refusal in &observed.refusals {
673 sink.event(Event::BeginMap(Shape::Block));
674 emit_path_fields(sink, &refusal.path);
675 emit_str_field(sink, "reason", refusal.reason.label());
676 sink.event(Event::EndMap);
677 }
678 sink.event(Event::EndSeq);
679 });
680 sink.event(Event::EndMap);
681}
682
683fn emit_analysis(sink: &mut impl Sink, analysis: Option<&crate::query::ContentReportMetadata>) {
684 let Some(analysis) = analysis else {
685 emit_scalar(sink, Scalar::Null);
686 return;
687 };
688 sink.event(Event::BeginMap(Shape::Block));
689 emit_field(sink, Field::always("analyze"), true, |sink| {
690 sink.event(Event::BeginSeq(Shape::Inline));
691 for label in analysis_set_labels(analysis.profile) {
692 emit_scalar(sink, Scalar::Str(label));
693 }
694 sink.event(Event::EndSeq);
695 });
696 emit_u64_field(sink, "type_rules_fingerprint", analysis.provenance.type_rules_fingerprint);
697 emit_u64_field(sink, "options_fingerprint", analysis.provenance.options_fingerprint.0);
698 emit_field(sink, Field::always("analyzers"), true, |sink| {
699 sink.event(Event::BeginSeq(Shape::Block));
700 for (id, version) in &analysis.provenance.analyzers {
701 sink.event(Event::BeginMap(Shape::Inline));
702 emit_str_field(sink, "id", id.0);
703 emit_u64_field(sink, "version", u64::from(version.0));
704 sink.event(Event::EndMap);
705 }
706 sink.event(Event::EndSeq);
707 });
708 sink.event(Event::EndMap);
709}
710
711fn emit_optional_usize(sink: &mut impl Sink, value: Option<usize>) {
712 match value {
713 Some(value) => emit_scalar(sink, Scalar::U64(value as u64)),
714 None => emit_scalar(sink, Scalar::Null),
715 }
716}
717
718fn emit_str_field(sink: &mut impl Sink, name: &'static str, value: &str) {
719 emit_field(sink, Field::always(name), true, |sink| {
720 emit_scalar(sink, Scalar::Str(value));
721 });
722}
723
724fn emit_u64_field(sink: &mut impl Sink, name: &'static str, value: u64) {
725 emit_field(sink, Field::always(name), true, |sink| {
726 emit_scalar(sink, Scalar::U64(value));
727 });
728}
729
730fn emit_bool_field(sink: &mut impl Sink, name: &'static str, value: bool) {
731 emit_field(sink, Field::always(name), true, |sink| {
732 emit_scalar(sink, Scalar::Bool(value));
733 });
734}
735
736fn emit_i64_field(sink: &mut impl Sink, name: &'static str, value: i64) {
737 emit_field(sink, Field::always(name), true, |sink| {
738 emit_scalar(sink, Scalar::I64(value));
739 });
740}
741
742fn emit_raw_identity(sink: &mut impl Sink, name: &'static str, path: &Path) {
743 let raw = raw_os_identity(path.as_os_str());
744 emit_field(sink, Field::when_lossy(name), raw.is_some(), |sink| {
745 let (encoding, hex) = raw.expect("lossy field predicate checked the raw identity");
746 sink.event(Event::BeginMap(Shape::Inline));
747 emit_str_field(sink, "encoding", encoding);
748 emit_str_field(sink, "hex", &hex);
749 sink.event(Event::EndMap);
750 });
751}
752
753fn emit_path_fields(sink: &mut impl Sink, path: &Path) {
754 let lossy = path.to_string_lossy();
755 emit_str_field(sink, "path", &lossy);
756 emit_raw_identity(sink, "path_raw", path);
757}
758
759fn emit_section(sink: &mut impl Sink, section: &Section) {
760 sink.event(Event::BeginMap(Shape::Block));
761 emit_str_field(sink, "view", section.view().label());
762 match section {
763 Section::Code(overview) => {
764 emit_field(sink, Field::always("code"), true, |sink| {
765 emit_code_overview(sink, overview);
766 });
767 }
768 Section::Tree { root, omissions, limits, .. } => {
769 sink.event(Event::Key("limits"));
770 sink.event(Event::BeginMap(Shape::Inline));
771 emit_bound_value(sink, "depth", limits.depth);
772 emit_str_field(sink, "min_share", &limits.min_share.label());
773 emit_bound_value(sink, "breadth", limits.breadth);
774 emit_bound_value(sink, "rows", limits.rows);
775 sink.event(Event::EndMap);
776 emit_field(sink, Field::always("tree"), true, |sink| match root {
777 Some(root) => emit_tree(sink, root),
778 None => emit_scalar(sink, Scalar::Null),
779 });
780 emit_tree_omissions(sink, omissions);
781 sink.event(Event::Key("remainder"));
782 match crate::query::TreeRemainder::from_tree(root.as_deref(), omissions) {
783 Some(remainder) => {
784 sink.event(Event::BeginMap(Shape::Block));
785 for (key, value) in [
786 ("files", remainder.files),
787 ("bytes", remainder.bytes),
788 ("allocated", remainder.allocated),
789 ] {
790 sink.event(Event::Key(key));
791 emit_scalar(sink, value.map_or(Scalar::Null, Scalar::U64));
792 }
793 sink.event(Event::Key("reasons"));
794 sink.event(Event::BeginSeq(Shape::Inline));
795 for reason in remainder.reasons {
796 emit_scalar(sink, Scalar::Str(reason.label()));
797 }
798 sink.event(Event::EndSeq);
799 sink.event(Event::EndMap);
800 }
801 None => emit_scalar(sink, Scalar::Null),
802 }
803 }
804 Section::Extensions { rows, total, share_omitted } => {
805 emit_bound_field(sink, rows.len(), *total);
806 emit_u64_field(sink, "share_omitted", *share_omitted as u64);
807 emit_field(sink, Field::always("extensions"), true, |sink| {
808 sink.event(Event::BeginSeq(Shape::Block));
809 for row in rows {
810 sink.event(Event::BeginMap(Shape::Block));
811 emit_str_field(sink, "extension", &row.extension);
812 emit_u64_field(sink, "files", row.files);
813 emit_u64_field(sink, "bytes", row.bytes);
814 emit_u64_field(sink, "allocated", row.allocated);
815 emit_field(sink, Field::nullable("ignored"), true, |sink| {
816 emit_ignored(sink, row.ignored, false);
817 });
818 sink.event(Event::EndMap);
819 }
820 sink.event(Event::EndSeq);
821 });
822 }
823 Section::Metrics { summary, .. } => {
824 emit_field(sink, Field::always("metrics"), true, |sink| {
825 emit_metric_summary(sink, summary);
826 });
827 }
828 Section::Files { rows, total, .. } => {
829 emit_bound_field(sink, rows.len(), *total);
830 emit_field(sink, Field::always("files"), true, |sink| {
831 sink.event(Event::BeginSeq(Shape::Block));
832 for row in rows {
833 emit_file_row(sink, row);
834 }
835 sink.event(Event::EndSeq);
836 });
837 }
838 Section::Summary(row) => {
839 emit_field(sink, Field::always("summary"), true, |sink| {
840 emit_summary_row(sink, row);
841 });
842 }
843 }
844 sink.event(Event::EndMap);
845}
846
847fn emit_bound_value(sink: &mut impl Sink, field: &'static str, bound: crate::query::Bound) {
848 sink.event(Event::Key(field));
849 match bound {
850 crate::query::Bound::All => emit_scalar(sink, Scalar::Null),
851 crate::query::Bound::Limit(value) => emit_scalar(sink, Scalar::U64(value as u64)),
852 }
853}
854
855fn emit_bound_field(sink: &mut impl Sink, shown: usize, total: usize) {
856 emit_field(sink, Field::nullable("bound"), true, |sink| {
857 if shown >= total {
858 emit_scalar(sink, Scalar::Null);
859 } else {
860 sink.event(Event::BeginMap(Shape::Inline));
861 emit_u64_field(sink, "shown", shown as u64);
862 emit_u64_field(sink, "total", total as u64);
863 sink.event(Event::EndMap);
864 }
865 });
866}
867
868fn emit_file_row(sink: &mut impl Sink, row: &FileRow) {
869 sink.event(Event::BeginMap(Shape::Block));
870 emit_path_fields(sink, &row.path);
871 emit_str_field(sink, "kind", kind_label(row.kind));
872 emit_u64_field(sink, "bytes", row.bytes);
873 emit_u64_field(sink, "allocated", row.allocated);
874 emit_i64_field(sink, "mtime_ns", row.mtime_ns);
875 for (name, value) in [("files", row.files), ("dirs", row.dirs)] {
876 emit_field(sink, Field::nullable(name), true, |sink| match value {
877 Some(value) => emit_scalar(sink, Scalar::U64(value)),
878 None => emit_scalar(sink, Scalar::Null),
879 });
880 }
881 emit_field(sink, Field::nullable("complete"), true, |sink| match row.complete {
882 Some(value) => emit_scalar(sink, Scalar::Bool(value)),
883 None => emit_scalar(sink, Scalar::Null),
884 });
885 emit_field(sink, Field::nullable("age_ns"), true, |sink| match row.age_ns {
886 Some(value) => emit_scalar(sink, Scalar::I128(value)),
887 None => emit_scalar(sink, Scalar::Null),
888 });
889
890 emit_field(sink, Field::nullable("ignored"), true, |sink| match row.ignored {
891 Some(value) => emit_scalar(sink, Scalar::Bool(value)),
892 None => emit_scalar(sink, Scalar::Null),
893 });
894 emit_field(sink, Field::nullable("sort_value"), true, |sink| match row.sort_value {
895 Some(value) => emit_scalar(sink, Scalar::U64(value)),
896 None => emit_scalar(sink, Scalar::Null),
897 });
898 emit_field(sink, Field::nullable("classification"), true, |sink| match &row.classification {
899 Some(classification) => {
900 sink.event(Event::BeginMap(Shape::Block));
901 emit_str_field(sink, "file_type", classification.file_type.as_str());
902 emit_str_field(sink, "family", classification.family.as_str());
903 emit_str_field(sink, "source", classification.source.as_str());
904 emit_str_field(sink, "confidence", classification.confidence.as_str());
905 emit_field(sink, Field::always("flags"), true, |sink| {
906 sink.event(Event::BeginMap(Shape::Inline));
907 emit_bool_field(sink, "generated", classification.flags.generated);
908 emit_bool_field(sink, "vendored", classification.flags.vendored);
909 emit_bool_field(sink, "documentation", classification.flags.documentation);
910 sink.event(Event::EndMap);
911 });
912 sink.event(Event::EndMap);
913 }
914 None => emit_scalar(sink, Scalar::Null),
915 });
916 sink.event(Event::EndMap);
917}
918
919fn emit_summary_row(sink: &mut impl Sink, row: &SummaryRow) {
920 sink.event(Event::BeginMap(Shape::Block));
921 emit_u64_field(sink, "files", row.files);
922 emit_u64_field(sink, "dirs", row.dirs);
923 emit_u64_field(sink, "bytes", row.bytes);
924 emit_u64_field(sink, "allocated", row.allocated);
925 emit_field(sink, Field::nullable("ignored"), true, |sink| {
926 emit_ignored(sink, row.ignored, true);
927 });
928 emit_field(sink, Field::nullable("newest_mtime_ns"), true, |sink| match row.newest_mtime_ns {
929 Some(value) => emit_scalar(sink, Scalar::I64(value)),
930 None => emit_scalar(sink, Scalar::Null),
931 });
932 sink.event(Event::EndMap);
933}
934
935fn emit_ignored(sink: &mut impl Sink, ignored: Option<IgnoredTally>, with_dirs: bool) {
936 let Some(ignored) = ignored else {
937 emit_scalar(sink, Scalar::Null);
938 return;
939 };
940 sink.event(Event::BeginMap(Shape::Inline));
941 emit_u64_field(sink, "files", ignored.files);
942 if with_dirs {
943 emit_u64_field(sink, "dirs", ignored.dirs);
944 }
945 emit_u64_field(sink, "bytes", ignored.bytes);
946 emit_u64_field(sink, "allocated", ignored.allocated);
947 sink.event(Event::EndMap);
948}
949
950fn emit_metric_summary(sink: &mut impl Sink, summary: &MetricSummary) {
951 sink.event(Event::BeginMap(Shape::Block));
952 emit_str_field(sink, "group", metric_group_label(summary.group));
953 emit_str_field(sink, "share_metric", summary.share_metric.as_str());
954 emit_bound_field(sink, summary.rows.len(), summary.total_rows);
955 emit_u64_field(sink, "share_omitted", summary.share_omitted as u64);
956 emit_field(sink, Field::always("total"), true, |sink| {
957 emit_metric_row(sink, &summary.total, summary.words_per_page);
958 });
959 emit_field(sink, Field::always("rows"), true, |sink| {
960 sink.event(Event::BeginSeq(Shape::Block));
961 for row in &summary.rows {
962 emit_metric_row(sink, row, summary.words_per_page);
963 }
964 sink.event(Event::EndSeq);
965 });
966 sink.event(Event::EndMap);
967}
968
969fn emit_code_tally(sink: &mut impl Sink, tally: &CodeTally) {
970 sink.event(Event::BeginMap(Shape::Block));
971 emit_u64_field(sink, "source_files", tally.source_files);
972 emit_u64_field(sink, "analyzed_files", tally.analyzed_files);
973 emit_u64_field(sink, "code_lines", tally.metrics.code_lines);
974 emit_u64_field(sink, "comment_lines", tally.metrics.comment_lines);
975 emit_u64_field(sink, "blank_lines", tally.metrics.code_blank_lines);
976 emit_u64_field(sink, "missing_records", tally.missing_records);
977 emit_field(sink, Field::always("coverage"), true, |sink| {
978 emit_coverage_map(sink, &tally.coverage);
979 });
980 sink.event(Event::EndMap);
981}
982
983fn emit_optional_code_tally(sink: &mut impl Sink, tally: Option<&CodeTally>) {
984 match tally {
985 Some(tally) => emit_code_tally(sink, tally),
986 None => emit_scalar(sink, Scalar::Null),
987 }
988}
989
990fn emit_code_overview(sink: &mut impl Sink, overview: &CodeOverview) {
991 sink.event(Event::BeginMap(Shape::Block));
992 emit_str_field(sink, "population", overview.population.label());
993 emit_str_field(sink, "share_metric", overview.share_metric.as_str());
994 emit_bound_field(sink, overview.languages.len(), overview.total_languages);
995 emit_u64_field(sink, "share_omitted", overview.share_omitted as u64);
996 emit_u64_field(sink, "analyzed_languages", overview.analyzed_languages);
997 emit_u64_field(sink, "unclassified_files", overview.unclassified_files);
998 emit_field(sink, Field::always("selected"), true, |sink| {
999 emit_code_tally(sink, &overview.selected);
1000 });
1001 emit_field(sink, Field::nullable("non_ignored"), true, |sink| {
1002 emit_optional_code_tally(sink, overview.non_ignored.as_ref());
1003 });
1004 emit_field(sink, Field::nullable("ignored"), true, |sink| {
1005 emit_optional_code_tally(sink, overview.ignored.as_ref());
1006 });
1007 emit_field(sink, Field::always("unknown"), true, |sink| {
1008 emit_code_tally(sink, &overview.unknown);
1009 });
1010 emit_field(sink, Field::always("languages"), true, |sink| {
1011 sink.event(Event::BeginSeq(Shape::Block));
1012 for row in &overview.languages {
1013 sink.event(Event::BeginMap(Shape::Block));
1014 emit_str_field(sink, "language", &row.language);
1015 emit_field(sink, Field::always("share"), true, |sink| {
1016 sink.event(Event::BeginMap(Shape::Inline));
1017 emit_u64_field(sink, "numerator", row.share.numerator);
1018 emit_u64_field(sink, "denominator", row.share.denominator);
1019 sink.event(Event::EndMap);
1020 });
1021 emit_field(sink, Field::always("selected"), true, |sink| {
1022 emit_code_tally(sink, &row.selected);
1023 });
1024 emit_field(sink, Field::nullable("non_ignored"), true, |sink| {
1025 emit_optional_code_tally(sink, row.non_ignored.as_ref());
1026 });
1027 emit_field(sink, Field::nullable("ignored"), true, |sink| {
1028 emit_optional_code_tally(sink, row.ignored.as_ref());
1029 });
1030 emit_field(sink, Field::always("unknown"), true, |sink| {
1031 emit_code_tally(sink, &row.unknown);
1032 });
1033 sink.event(Event::EndMap);
1034 }
1035 sink.event(Event::EndSeq);
1036 });
1037 sink.event(Event::EndMap);
1038}
1039
1040fn emit_metric_row(sink: &mut impl Sink, row: &MetricRow, words_per_page: u64) {
1041 sink.event(Event::BeginMap(Shape::Block));
1042 emit_str_field(sink, "id", &row.id);
1043 emit_str_field(sink, "family", row.family.as_str());
1044 emit_u64_field(sink, "files", row.files);
1045 emit_u64_field(sink, "bytes", row.bytes);
1046 emit_u64_field(sink, "allocated", row.allocated);
1047 emit_field(sink, Field::always("share"), true, |sink| {
1048 sink.event(Event::BeginMap(Shape::Inline));
1049 emit_u64_field(sink, "numerator", row.share.numerator);
1050 emit_u64_field(sink, "denominator", row.share.denominator);
1051 sink.event(Event::EndMap);
1052 });
1053 emit_field(sink, Field::always("metrics"), true, |sink| {
1054 emit_metric_values(sink, row);
1055 });
1056 emit_field(sink, Field::always("coverage"), true, |sink| {
1057 sink.event(Event::BeginMap(Shape::Block));
1058 emit_analyzer_field(
1059 sink,
1060 row.analysis,
1061 Field::when_analyzer("lines", crate::content::AnalysisSet::LINES_ONLY),
1062 |sink| emit_coverage_map(sink, &row.lines_coverage),
1063 );
1064 emit_analyzer_field(
1065 sink,
1066 row.analysis,
1067 Field::when_analyzer("code", crate::content::AnalysisSet::CODE_ONLY),
1068 |sink| emit_coverage_map(sink, row.code_coverage.as_ref().expect("code requested")),
1069 );
1070 emit_analyzer_field(
1071 sink,
1072 row.analysis,
1073 Field::when_analyzer("words", crate::content::AnalysisSet::WORDS_ONLY),
1074 |sink| emit_coverage_map(sink, row.words_coverage.as_ref().expect("words requested")),
1075 );
1076 sink.event(Event::EndMap);
1077 });
1078 emit_field(sink, Field::always("detection"), true, |sink| {
1079 emit_detection(sink, row);
1080 });
1081 emit_analyzer_field(
1082 sink,
1083 row.analysis,
1084 Field::when_analyzer("pages", crate::content::AnalysisSet::WORDS_ONLY),
1085 |sink| {
1086 let page = pages(row, words_per_page).expect("words request has page inputs");
1087 sink.event(Event::BeginMap(Shape::Inline));
1088 emit_u64_field(sink, "words", page.words);
1089 emit_u64_field(sink, "words_per_page", page.words_per_page);
1090 sink.event(Event::EndMap);
1091 },
1092 );
1093 sink.event(Event::EndMap);
1094}
1095
1096fn emit_metric_values(sink: &mut impl Sink, row: &MetricRow) {
1097 sink.event(Event::BeginMap(Shape::Inline));
1098 for metric in METRICS {
1099 emit_analyzer_field(
1100 sink,
1101 row.analysis,
1102 Field::when_analyzer(metric.name, metric.owner),
1103 |sink| {
1104 emit_scalar(
1105 sink,
1106 Scalar::U64(row.metric_value(metric).expect("metric owner requested")),
1107 );
1108 },
1109 );
1110 }
1111 sink.event(Event::EndMap);
1112}
1113
1114fn emit_coverage_map(
1115 sink: &mut impl Sink,
1116 coverage: &std::collections::BTreeMap<CoverageReason, u64>,
1117) {
1118 sink.event(Event::BeginMap(Shape::Inline));
1119 for (reason, count) in coverage {
1120 emit_u64_field(sink, coverage_label(*reason), *count);
1121 }
1122 sink.event(Event::EndMap);
1123}
1124
1125fn emit_detection(sink: &mut impl Sink, row: &MetricRow) {
1126 sink.event(Event::BeginMap(Shape::Block));
1127 emit_field(sink, Field::always("sources"), true, |sink| {
1128 sink.event(Event::BeginMap(Shape::Inline));
1129 for (source, count) in &row.detection_sources {
1130 emit_u64_field(sink, source.as_str(), *count);
1131 }
1132 sink.event(Event::EndMap);
1133 });
1134 emit_field(sink, Field::always("confidence"), true, |sink| {
1135 sink.event(Event::BeginMap(Shape::Inline));
1136 for (level, count) in &row.detection_confidence {
1137 emit_u64_field(sink, level.as_str(), *count);
1138 }
1139 sink.event(Event::EndMap);
1140 });
1141 emit_field(sink, Field::always("flags"), true, |sink| {
1142 sink.event(Event::BeginMap(Shape::Inline));
1143 emit_u64_field(sink, "generated", row.generated_files);
1144 emit_u64_field(sink, "vendored", row.vendored_files);
1145 emit_u64_field(sink, "documentation", row.documentation_files);
1146 sink.event(Event::EndMap);
1147 });
1148 sink.event(Event::EndMap);
1149}
1150
1151fn emit_tree(sink: &mut impl Sink, root: &TreeNode) {
1152 enum Step<'a> {
1153 Node(&'a TreeNode),
1154 Children(std::slice::Iter<'a, TreeNode>),
1155 EndMap,
1156 EndSeq,
1157 }
1158 let mut stack = vec![Step::Node(root)];
1159 while let Some(step) = stack.pop() {
1160 match step {
1161 Step::Node(node) => {
1162 sink.event(Event::BeginMap(Shape::Block));
1163 emit_str_field(sink, "name", &node.name);
1164 emit_path_fields(sink, &node.path);
1165 emit_str_field(sink, "kind", kind_label(node.kind));
1166 emit_field(sink, Field::nullable("entry_ignored"), true, |sink| {
1167 emit_scalar(sink, node.entry_ignored.map_or(Scalar::Null, Scalar::Bool));
1168 });
1169 emit_u64_field(sink, "bytes", node.bytes);
1170 emit_u64_field(sink, "allocated", node.allocated);
1171 emit_u64_field(sink, "files", node.files);
1172 emit_u64_field(sink, "dirs", node.dirs);
1173 emit_field(sink, Field::nullable("ignored"), true, |sink| {
1174 emit_ignored(sink, node.ignored, true);
1175 });
1176 emit_field(sink, Field::nullable("newest_mtime_ns"), true, |sink| {
1177 match node.newest_mtime_ns {
1178 Some(value) => emit_scalar(sink, Scalar::I64(value)),
1179 None => emit_scalar(sink, Scalar::Null),
1180 }
1181 });
1182 emit_field(sink, Field::always("truncated"), true, |sink| {
1183 emit_scalar(sink, Scalar::Bool(node.truncated));
1184 });
1185 emit_tree_omissions(sink, &node.omissions);
1186 sink.event(Event::Key("children"));
1187 sink.event(Event::BeginSeq(Shape::Block));
1188 stack.push(Step::EndMap);
1189 stack.push(Step::EndSeq);
1190 stack.push(Step::Children(node.children.iter()));
1191 }
1192 Step::Children(mut children) => {
1193 if let Some(child) = children.next() {
1194 stack.push(Step::Children(children));
1195 stack.push(Step::Node(child));
1196 }
1197 }
1198 Step::EndMap => sink.event(Event::EndMap),
1199 Step::EndSeq => sink.event(Event::EndSeq),
1200 }
1201 }
1202}
1203
1204fn emit_tree_omissions(sink: &mut impl Sink, omissions: &[crate::query::TreeOmission]) {
1205 sink.event(Event::Key("omissions"));
1206 sink.event(Event::BeginSeq(Shape::Block));
1207 for omission in omissions {
1208 sink.event(Event::BeginMap(Shape::Inline));
1209 emit_str_field(sink, "reason", omission.reason.label());
1210 emit_u64_field(sink, "entries", omission.entries as u64);
1211 sink.event(Event::Key("files"));
1212 emit_scalar(sink, omission.files.map_or(Scalar::Null, Scalar::U64));
1213 sink.event(Event::Key("bytes"));
1214 match omission.bytes {
1215 Some(value) => emit_scalar(sink, Scalar::U64(value)),
1216 None => emit_scalar(sink, Scalar::Null),
1217 }
1218 sink.event(Event::Key("allocated"));
1219 match omission.allocated {
1220 Some(value) => emit_scalar(sink, Scalar::U64(value)),
1221 None => emit_scalar(sink, Scalar::Null),
1222 }
1223 sink.event(Event::EndMap);
1224 }
1225 sink.event(Event::EndSeq);
1226}
1227
1228struct ReportFields {
1230 schema: Field,
1231 generator: Field,
1232 root: Field,
1233 root_raw: Field,
1234 request: Field,
1235 status: Field,
1236 provenance: Field,
1237 ignore_rules: Field,
1238 analysis: Field,
1239 reports: Field,
1240 age_reference_ns: Field,
1241}
1242
1243const REPORT_FIELDS: ReportFields = ReportFields {
1244 schema: Field::always("schema"),
1245 generator: Field::always("generator"),
1246 root: Field::always("root"),
1247 root_raw: Field::when_lossy("root_raw"),
1248 request: Field::always("request"),
1249 status: Field::always("status"),
1250 provenance: Field::always("provenance"),
1251 ignore_rules: Field::always("ignore_rules"),
1252 analysis: Field::nullable("analysis"),
1253 reports: Field::when_set("reports"),
1254 age_reference_ns: Field::nullable("age_reference_ns"),
1255};
1256
1257#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1259enum Presence {
1260 Always,
1261 Nullable,
1262 WhenLossy,
1263 WhenAnalyzer(crate::content::AnalysisSet),
1264 WhenSet,
1265}
1266
1267#[derive(Clone, Copy, Debug)]
1269struct Field {
1270 name: &'static str,
1271 presence: Presence,
1272}
1273
1274impl Field {
1275 const fn always(name: &'static str) -> Self {
1276 Self { name, presence: Presence::Always }
1277 }
1278
1279 const fn nullable(name: &'static str) -> Self {
1280 Self { name, presence: Presence::Nullable }
1281 }
1282
1283 const fn when_lossy(name: &'static str) -> Self {
1284 Self { name, presence: Presence::WhenLossy }
1285 }
1286
1287 const fn when_analyzer(name: &'static str, analysis: crate::content::AnalysisSet) -> Self {
1288 Self { name, presence: Presence::WhenAnalyzer(analysis) }
1289 }
1290
1291 const fn when_set(name: &'static str) -> Self {
1292 Self { name, presence: Presence::WhenSet }
1293 }
1294}
1295
1296pub fn paint(text: &str, style: AnsiStyle, color: bool) -> String {
1298 if color { format!("{style}{text}{style:#}") } else { text.to_string() }
1299}
1300
1301pub fn escaped_human(text: &str) -> String {
1303 text.chars()
1304 .flat_map(|c| if c.is_control() { c.escape_default().collect::<Vec<_>>() } else { vec![c] })
1305 .collect()
1306}
1307
1308pub fn byte_style(bytes: u64, secondary: bool) -> AnsiStyle {
1311 let style = if secondary || bytes == 0 { STYLE_DETAIL } else { AnsiStyle::new() };
1312 if bytes >= 1 << 30 { style.bold() } else { style }
1313}
1314
1315pub fn styled_bytes(bytes: u64, width: usize, color: bool, secondary: bool) -> String {
1318 let style = byte_style(bytes, secondary);
1319 let text = format!("{:>width$}", human_bytes(bytes));
1320 if bytes > 0 && bytes < 1 << 30 && !secondary { text } else { paint(&text, style, color) }
1321}
1322
1323fn percentage_cell(part: u64, whole: u64, decimals: usize, width: usize, color: bool) -> String {
1325 let text = format!("{:>width$}", human_percentage(part, whole, decimals));
1326 if whole > 0 && u128::from(part) * 100 < u128::from(whole) {
1327 detail(&text, color)
1328 } else {
1329 text
1330 }
1331}
1332
1333fn human_name(name: &str, kind: EntryKind, ignored: Option<bool>, color: bool) -> String {
1335 let style = if kind == EntryKind::Dir && ignored == Some(true) {
1338 STYLE_IGNORED_NAME
1339 } else {
1340 STYLE_NAME
1341 };
1342 let slash = kind == EntryKind::Dir && !matches!(name, "." | "..") && !name.ends_with('/');
1343 format!(
1344 "{}{}",
1345 paint(&escaped_human(name), style, color),
1346 if slash { detail("/", color) } else { String::new() }
1347 )
1348}
1349
1350fn detail(text: &str, color: bool) -> String {
1351 paint(text, STYLE_DETAIL, color)
1352}
1353
1354fn render_text(report: &Report, options: RenderOptions) -> String {
1370 let color = options.color;
1371 let mut out = String::new();
1372 let headed = report.sections.len() > 1;
1373 for (index, section) in report.sections.iter().enumerate() {
1374 if index > 0 {
1375 out.push('\n');
1376 }
1377 if headed {
1380 let _ = writeln!(
1381 out,
1382 "{}{}",
1383 paint(view_header(section.view()), STYLE_HEADING, color),
1384 paint(&bound_note(section), STYLE_DETAIL, color),
1385 );
1386 }
1387 match section {
1388 Section::Code(overview) => render_text_code(&mut out, overview, color),
1389 Section::Tree { root, omissions, limits, .. } => {
1390 render_text_tree(
1391 &mut out,
1392 root.as_deref(),
1393 omissions,
1394 limits,
1395 report.size,
1396 report.ignored_entries,
1397 options,
1398 );
1399 }
1400 Section::Extensions { rows, .. } => {
1401 render_text_types(&mut out, rows, report.size, report.ignored_entries, color);
1402 }
1403 Section::Metrics { view, summary } => {
1404 render_text_metrics(&mut out, *view, summary, report.size, color);
1405 }
1406 Section::Files { rows, .. } if report.sort_metric.is_some() => {
1413 render_text_metric_files(&mut out, rows, color);
1414 }
1415 Section::Files { view, rows, .. } => match view {
1416 ViewSpec::Largest => {
1417 render_text_ranked_files(&mut out, rows, color, Some(report.size), |row| {
1418 human_bytes(pick(report.size, row.bytes, row.allocated))
1419 });
1420 }
1421 ViewSpec::Recent => render_text_ranked_files(&mut out, rows, color, None, |row| {
1422 format_rfc3339_nanos(row.mtime_ns)
1423 }),
1424 _ => {
1425 for row in rows {
1426 let _ = writeln!(out, "{}", escaped_human(&row.path.to_string_lossy()));
1427 }
1428 }
1429 },
1430 Section::Summary(row) => {
1431 render_text_summary(&mut out, row, report.size, report.ignored_entries, color);
1432 }
1433 }
1434 }
1435 out
1436}
1437
1438const TEXT_SIZE_WIDTH: usize = 10;
1471const TEXT_SHARE_WIDTH: usize = 6;
1473const TEXT_CONTINUATION_INDENT: usize = 2;
1475
1476fn label_cell(label: &str, width: usize, style: AnsiStyle, color: bool) -> String {
1478 let padding = " ".repeat(width.saturating_sub(display_width(label)));
1479 format!("{}{padding}", paint(label, style, color))
1480}
1481
1482fn label_width<'a>(labels: impl Iterator<Item = &'a str>, minimum: usize) -> usize {
1484 minimum.max(labels.map(|label| display_width(&escaped_human(label))).max().unwrap_or_default())
1485}
1486
1487fn display_width(text: &str) -> usize {
1492 text.chars()
1493 .map(|c| {
1494 let point = c as u32;
1495 if (0x0300..=0x036f).contains(&point)
1496 || (0x1ab0..=0x1aff).contains(&point)
1497 || (0x1dc0..=0x1dff).contains(&point)
1498 || (0x20d0..=0x20ff).contains(&point)
1499 || (0xfe20..=0xfe2f).contains(&point)
1500 {
1501 0
1502 } else if (0x1100..=0x115f).contains(&point)
1503 || (0x2e80..=0xa4cf).contains(&point)
1504 || (0xac00..=0xd7a3).contains(&point)
1505 || (0xf900..=0xfaff).contains(&point)
1506 || (0xfe10..=0xfe19).contains(&point)
1507 || (0xfe30..=0xfe6f).contains(&point)
1508 || (0xff01..=0xff60).contains(&point)
1509 || (0xffe0..=0xffe6).contains(&point)
1510 || (0x1f300..=0x1faff).contains(&point)
1511 || (0x20000..=0x3fffd).contains(&point)
1512 {
1513 2
1514 } else {
1515 1
1516 }
1517 })
1518 .sum()
1519}
1520
1521fn human_percentage(part: u64, whole: u64, decimals: usize) -> String {
1522 if whole == 0 {
1523 return "—".to_string();
1524 }
1525 let scale = match decimals {
1526 0 => 100_u128,
1527 1 => 1_000_u128,
1528 _ => panic!("human percentage supports whole and tenths only"),
1529 };
1530 if part > 0 && u128::from(part) * scale < u128::from(whole) {
1533 return if decimals == 0 {
1534 "<1%".to_string()
1535 } else {
1536 format!("<0.{}1%", "0".repeat(decimals - 1))
1537 };
1538 }
1539 format!("{:.*}%", decimals, ratio(part, whole) * 100.0)
1540}
1541
1542fn render_text_metrics(
1547 out: &mut String,
1548 view: ViewSpec,
1549 summary: &MetricSummary,
1550 size: SizeMetric,
1551 color: bool,
1552) {
1553 let width = if view == ViewSpec::Languages {
1556 label_width(summary.rows.iter().map(|row| human_metric_label(view, &row.id)), 0)
1557 .saturating_add(1)
1558 } else {
1559 label_width(
1560 summary.rows.iter().map(|row| human_metric_label(view, &row.id)),
1561 TEXT_METRIC_LABEL_WIDTH,
1562 )
1563 };
1564 let continuation = " "
1567 .repeat(TEXT_SIZE_WIDTH + 2 + TEXT_SHARE_WIDTH + 2 + width + 1 + TEXT_CONTINUATION_INDENT);
1568 for row in &summary.rows {
1569 let selected = pick(size, row.bytes, row.allocated);
1570 let percentage =
1571 percentage_cell(row.share.numerator, row.share.denominator, 1, TEXT_SHARE_WIDTH, color);
1572 let mut measures = Vec::new();
1573 if let Some(physical_lines) = row.metrics.physical_lines.filter(|lines| *lines > 0) {
1574 let code_fully_analyzed = row.code_coverage.as_ref().is_some_and(|coverage| {
1575 coverage.len() == 1 && coverage.get(&CoverageReason::Analyzed) == Some(&row.files)
1576 });
1577 let breakdown =
1578 if let (true, Some(code_lines), Some(comment_lines), Some(code_blank_lines)) = (
1579 code_fully_analyzed,
1580 row.metrics.code_lines,
1581 row.metrics.comment_lines,
1582 row.metrics.code_blank_lines,
1583 ) {
1584 format!(
1585 "({} code, {} comment, {} blank)",
1586 human_count(code_lines),
1587 human_count(comment_lines),
1588 human_count(code_blank_lines)
1589 )
1590 } else {
1591 format!(
1592 "({} nonblank, {} blank)",
1593 human_count(row.metrics.nonblank_lines.expect("lines requested")),
1594 human_count(row.metrics.blank_lines.expect("lines requested"))
1595 )
1596 };
1597 measures.push(format!(
1598 "{} lines {}",
1599 human_count(physical_lines),
1600 detail(&breakdown, color)
1601 ));
1602 }
1603 if let Some(page) = pages(row, summary.words_per_page).filter(|page| page.words > 0) {
1604 let page_tenths = page.words.saturating_mul(10) / page.words_per_page;
1605 measures.push(format!(
1606 "{} words {}",
1607 human_count(page.words),
1608 detail(
1609 &format!("({}.{:01} pages)", human_count(page_tenths / 10), page_tenths % 10),
1610 color,
1611 )
1612 ));
1613 }
1614 for (count, flag) in [(row.generated_files, "generated"), (row.vendored_files, "vendored")]
1616 {
1617 if count > 0 {
1618 measures.push(format!("{} {flag}", human_count(count)));
1619 }
1620 }
1621 let coverage = match view {
1622 ViewSpec::Languages => row.code_coverage.as_ref().unwrap_or(&row.lines_coverage),
1623 ViewSpec::Documents => row.words_coverage.as_ref().unwrap_or(&row.lines_coverage),
1624 _ => &row.lines_coverage,
1625 };
1626 for (reason, count) in coverage {
1627 if *reason != CoverageReason::Analyzed {
1628 measures.push(format!("{} {}", human_count(*count), human_coverage_label(*reason)));
1629 }
1630 }
1631 let _ = writeln!(
1632 out,
1633 "{} {} {} {} {}",
1634 styled_bytes(selected, TEXT_SIZE_WIDTH, color, false),
1635 percentage,
1636 label_cell(
1637 &escaped_human(human_metric_label(view, &row.id)),
1638 width,
1639 STYLE_CATEGORY,
1640 color
1641 ),
1642 human_count(row.files),
1643 plural(row.files, "file", "files"),
1644 );
1645 for measure in measures {
1646 let _ = writeln!(out, "{continuation}{measure}");
1647 }
1648 }
1649}
1650
1651fn render_text_code(out: &mut String, overview: &CodeOverview, color: bool) {
1654 let selected = &overview.selected;
1655 let code_width = human_count(selected.metrics.code_lines).len().max("Code lines".len());
1656 let comment_width = human_count(selected.metrics.comment_lines).len().max("Comments".len());
1657 let blank_width = human_count(selected.metrics.code_blank_lines).len().max("Blank".len());
1658 let analyzed_width =
1659 format!("{}/{}", human_count(selected.analyzed_files), human_count(selected.source_files))
1660 .len()
1661 .max("Analyzed files".len());
1662 let language_width = label_width(
1663 overview.languages.iter().map(|row| human_language_name(&row.language)),
1664 "Language".len(),
1665 );
1666 let _ = writeln!(
1667 out,
1668 "{} {} {} {} {} {}",
1669 paint(&format!("{:>code_width$}", "Code lines"), STYLE_CATEGORY, color),
1670 paint(&format!("{:>6}", "Share"), STYLE_CATEGORY, color),
1671 paint(&format!("{:>comment_width$}", "Comments"), STYLE_CATEGORY, color),
1672 paint(&format!("{:>blank_width$}", "Blank"), STYLE_CATEGORY, color),
1673 paint(&format!("{:>analyzed_width$}", "Analyzed files"), STYLE_CATEGORY, color),
1674 paint("Language", STYLE_CATEGORY, color),
1675 );
1676 for row in &overview.languages {
1677 let measured = row.selected.analyzed_files > 0;
1678 let mut annotation = String::new();
1681 if let (true, Some(ignored)) = (measured, &row.ignored) {
1682 let _ = write!(annotation, "{} gitignored", human_count(ignored.metrics.code_lines));
1683 }
1684 if measured && row.unknown.source_files > 0 {
1685 let _ = write!(
1686 annotation,
1687 "{}{} unknown",
1688 if annotation.is_empty() { "" } else { ", " },
1689 human_count(row.unknown.metrics.code_lines)
1690 );
1691 }
1692 let annotation = if annotation.is_empty() {
1693 String::new()
1694 } else {
1695 format!(" {}", detail(&format!("({annotation})"), color))
1696 };
1697 let analyzed = format!(
1698 "{}/{}",
1699 human_count(row.selected.analyzed_files),
1700 human_count(row.selected.source_files)
1701 );
1702 let _ = writeln!(
1703 out,
1704 "{} {} {} {} {:>analyzed_width$} {}{}",
1705 code_cell(row.selected.metrics.code_lines, measured, code_width, color),
1706 if measured {
1707 percentage_cell(row.share.numerator, row.share.denominator, 1, 6, color)
1708 } else {
1709 detail(&format!("{:>6}", "—"), color)
1710 },
1711 code_cell(row.selected.metrics.comment_lines, measured, comment_width, color),
1712 code_cell(row.selected.metrics.code_blank_lines, measured, blank_width, color),
1713 analyzed,
1714 label_cell(
1715 human_language_name(&row.language),
1716 if annotation.is_empty() { 0 } else { language_width },
1717 STYLE_NAME,
1718 color
1719 ),
1720 annotation
1721 );
1722 }
1723 let mut total_detail = String::new();
1724 if let (true, Some(ignored)) = (selected.analyzed_files > 0, &overview.ignored) {
1725 let _ = write!(total_detail, "{} gitignored", human_count(ignored.metrics.code_lines));
1726 }
1727 if selected.analyzed_files > 0 && overview.unknown.source_files > 0 {
1728 let _ = write!(
1729 total_detail,
1730 "{}{} unknown",
1731 if total_detail.is_empty() { "" } else { ", " },
1732 human_count(overview.unknown.metrics.code_lines)
1733 );
1734 }
1735 let total_detail = if total_detail.is_empty() {
1736 String::new()
1737 } else {
1738 format!(" {}", detail(&format!("({total_detail})"), color))
1739 };
1740 let bold = AnsiStyle::new().bold();
1741 let total_share = if selected.analyzed_files > 0 {
1742 percentage_cell(selected.metrics.code_lines, selected.metrics.code_lines, 1, 6, false)
1743 } else {
1744 format!("{:>6}", "—")
1745 };
1746 let total_analyzed = format!(
1747 "{:>analyzed_width$}",
1748 format!("{}/{}", human_count(selected.analyzed_files), human_count(selected.source_files))
1749 );
1750 let _ = writeln!(
1751 out,
1752 "{} {} {} {} {} {}{}",
1753 code_total_cell(
1754 selected.metrics.code_lines,
1755 selected.analyzed_files > 0,
1756 code_width,
1757 color
1758 ),
1759 paint(&total_share, bold, color),
1760 code_total_cell(
1761 selected.metrics.comment_lines,
1762 selected.analyzed_files > 0,
1763 comment_width,
1764 color
1765 ),
1766 code_total_cell(
1767 selected.metrics.code_blank_lines,
1768 selected.analyzed_files > 0,
1769 blank_width,
1770 color
1771 ),
1772 paint(&total_analyzed, bold, color),
1773 paint(
1774 &format!(
1775 "{:<width$}",
1776 "TOTAL",
1777 width = if total_detail.is_empty() { 0 } else { language_width }
1778 ),
1779 bold,
1780 color
1781 ),
1782 total_detail
1783 );
1784 }
1787
1788fn code_cell(value: u64, measured: bool, width: usize, color: bool) -> String {
1789 if measured {
1790 format!("{:>width$}", human_count(value))
1791 } else {
1792 detail(&format!("{:>width$}", "—"), color)
1793 }
1794}
1795
1796fn code_total_cell(value: u64, measured: bool, width: usize, color: bool) -> String {
1797 let text = if measured { human_count(value) } else { "—".to_owned() };
1798 paint(&format!("{text:>width$}"), AnsiStyle::new().bold(), color)
1799}
1800
1801pub(super) fn share_metric_label(metric: ShareMetric) -> Option<&'static str> {
1808 match metric {
1809 ShareMetric::CodeLines => Some("code lines"),
1810 ShareMetric::DocumentWords => Some("document words"),
1811 ShareMetric::RawWords => Some("raw words"),
1812 ShareMetric::ApparentBytes | ShareMetric::AllocatedBytes => None,
1813 }
1814}
1815
1816fn human_metric_label(view: ViewSpec, id: &str) -> &str {
1817 if view == ViewSpec::Languages { human_language_name(id) } else { id }
1818}
1819
1820pub(super) fn human_coverage_label(reason: CoverageReason) -> &'static str {
1821 match reason {
1822 CoverageReason::Analyzed => "analyzed",
1823 CoverageReason::Binary => "binary",
1824 CoverageReason::InvalidUtf8 => "invalid UTF-8",
1825 CoverageReason::UnsupportedEncoding => "unsupported encoding",
1826 CoverageReason::Unsupported => "unsupported",
1827 CoverageReason::IoError => "I/O error",
1828 CoverageReason::ChangedDuringRead => "changed during read",
1829 CoverageReason::TextOnly => "counted as text",
1830 }
1831}
1832
1833fn ignored_suffix(
1845 ignored: Option<IgnoredTally>,
1846 size: SizeMetric,
1847 selected: IgnoredEntries,
1848 color: bool,
1849) -> String {
1850 let shown = match selected {
1851 IgnoredEntries::Include | IgnoredEntries::Exclude => {
1852 ignored.filter(|share| share.files > 0)
1853 }
1854 IgnoredEntries::Only => None,
1855 };
1856 shown.map_or_else(String::new, |share| {
1857 let bytes = pick(size, share.bytes, share.allocated);
1858 if bytes < 1 << 30 || !color {
1859 format!(" {}", detail(&format!("({} gitignored)", human_bytes(bytes)), color))
1860 } else {
1861 format!(
1862 " {}{}{}",
1863 detail("(", color),
1864 styled_bytes(bytes, 0, color, true),
1865 detail(" gitignored)", color)
1866 )
1867 }
1868 })
1869}
1870
1871fn render_text_tree(
1875 out: &mut String,
1876 root: Option<&TreeNode>,
1877 omissions: &[crate::query::TreeOmission],
1878 _limits: &crate::query::TreeDisplayLimits,
1879 size: SizeMetric,
1880 selected: IgnoredEntries,
1881 options: RenderOptions,
1882) {
1883 let RenderOptions { color, bar_size } = options;
1884 let hidden = crate::query::TreeRemainder::from_tree(root, omissions);
1885 if let Some(root) = root {
1886 let grand = pick(size, root.bytes, root.allocated);
1887 let mut stack = vec![(root, 0)];
1888 while let Some((node, depth)) = stack.pop() {
1889 let bytes = pick(size, node.bytes, node.allocated);
1890 let indent = " ".repeat(depth);
1891 let count = if node.kind == EntryKind::File {
1892 String::new()
1893 } else {
1894 format!(" {} {}", human_count(node.files), plural(node.files, "file", "files"))
1895 };
1896 let bar_prefix = if bar_size == 0 {
1897 String::new()
1898 } else {
1899 format!(
1900 "{} ",
1901 usage_bar(
1902 bytes,
1903 grand,
1904 node.ignored.map(|value| pick(size, value.bytes, value.allocated)),
1905 color,
1906 bar_size,
1907 )
1908 )
1909 };
1910 let _ = writeln!(
1911 out,
1912 "{bar_prefix}{} {} {indent}{}{}{}",
1913 percentage_cell(bytes, grand, 0, 5, color),
1914 styled_bytes(bytes, 10, color, false),
1915 human_name(&node.name, node.kind, node.entry_ignored, color),
1916 count,
1917 ignored_suffix(node.ignored, size, selected, color),
1918 );
1919 stack.extend(node.children.iter().rev().map(|child| (child, depth + 1)));
1920 }
1921 }
1922 if let Some(hidden) = hidden {
1925 let grand = root.map(|node| pick(size, node.bytes, node.allocated));
1926 render_tree_remainder(out, &hidden, grand, usize::from(root.is_some()), size, options);
1927 }
1928}
1929
1930fn render_tree_remainder(
1932 out: &mut String,
1933 remainder: &crate::query::TreeRemainder,
1934 grand: Option<u64>,
1935 depth: usize,
1936 size: SizeMetric,
1937 options: RenderOptions,
1938) {
1939 let RenderOptions { color, bar_size } = options;
1940 let bytes = match size {
1941 SizeMetric::Apparent => remainder.bytes,
1942 SizeMetric::Allocated => remainder.allocated,
1943 };
1944 let measure = bytes.map_or_else(
1945 || detail(&format!("{:>10}", "unknown"), color),
1946 |bytes| styled_bytes(bytes, 10, color, false),
1947 );
1948 let (usage_bar, percentage) = match bytes.zip(grand.or(bytes)) {
1951 Some((bytes, grand)) => (
1952 usage_bar(
1953 bytes,
1954 grand,
1955 remainder.ignored.map(|value| pick(size, value.bytes, value.allocated)),
1956 color,
1957 bar_size,
1958 ),
1959 percentage_cell(bytes, grand, 0, 5, color),
1960 ),
1961 None => (" ".repeat(bar_size), detail(&format!("{:>5}", "—"), color)),
1962 };
1963 let files = remainder.files.map_or_else(
1964 || format!("more files {}", detail("(count unknown)", color)),
1965 |files| format!("{} more {}", human_count(files), plural(files, "file", "files")),
1966 );
1967 let indent = " ".repeat(depth);
1968 let note = format!("{} {files}", detail(&format!("{indent}… and"), color));
1969 let bar_prefix = if bar_size == 0 { String::new() } else { format!("{usage_bar} ") };
1970 let _ = writeln!(out, "{bar_prefix}{percentage} {measure} {note}");
1971}
1972
1973fn render_text_types(
1975 out: &mut String,
1976 rows: &[TypeRow],
1977 size: SizeMetric,
1978 selected: IgnoredEntries,
1979 color: bool,
1980) {
1981 let width = label_width(rows.iter().map(|row| row.extension.as_str()), TEXT_TYPE_LABEL_WIDTH);
1982 for row in rows {
1983 let _ = writeln!(
1984 out,
1985 "{} {} {} {}{}",
1986 styled_bytes(pick(size, row.bytes, row.allocated), TEXT_SIZE_WIDTH, color, false),
1987 label_cell(&escaped_human(&row.extension), width, STYLE_CATEGORY, color),
1988 human_count(row.files),
1989 plural(row.files, "file", "files"),
1990 ignored_suffix(row.ignored, size, selected, color),
1991 );
1992 }
1993}
1994
1995fn bounded_rows(section: &Section) -> Option<(usize, usize)> {
1998 let (shown, total) = match section {
1999 Section::Code(overview) => (overview.languages.len(), overview.total_languages),
2000 Section::Files { rows, total, .. } => (rows.len(), *total),
2001 Section::Extensions { rows, total, .. } => (rows.len(), *total),
2002 Section::Metrics { summary, .. } => (summary.rows.len(), summary.total_rows),
2003 Section::Tree { .. } | Section::Summary(_) => return None,
2005 };
2006 (shown < total).then_some((shown, total))
2007}
2008
2009fn bound_note(section: &Section) -> String {
2012 let Some((shown, total)) = bounded_rows(section) else {
2013 return String::new();
2014 };
2015 format!(" ({} of {})", human_count(shown as u64), human_count(total as u64))
2016}
2017
2018fn render_text_ranked_files(
2023 out: &mut String,
2024 rows: &[FileRow],
2025 color: bool,
2026 size: Option<SizeMetric>,
2027 measure: impl Fn(&FileRow) -> String,
2028) {
2029 let width = rows.iter().map(|row| display_width(&measure(row))).max().unwrap_or_default();
2030 for row in rows {
2031 let value = size.map_or_else(
2032 || format!("{:>width$}", measure(row)),
2033 |size| styled_bytes(pick(size, row.bytes, row.allocated), width, color, false),
2034 );
2035 let _ = writeln!(
2036 out,
2037 "{} {}",
2038 value,
2039 human_name(&row.path.to_string_lossy(), row.kind, row.ignored, color)
2040 );
2041 }
2042}
2043
2044fn render_text_metric_files(out: &mut String, rows: &[FileRow], color: bool) {
2045 let width = rows
2046 .iter()
2047 .map(|row| row.sort_value.map_or(1, |value| human_count(value).len()))
2048 .max()
2049 .unwrap_or_default();
2050 for row in rows {
2051 let value = row.sort_value.map_or_else(|| "—".to_string(), human_count);
2052 let classification =
2053 row.classification.as_ref().map_or_else(String::new, |classification| {
2054 let mut parts =
2055 vec![classification.file_type.as_str(), classification.source.as_str()];
2056 if classification.flags.generated {
2057 parts.push("generated");
2058 }
2059 if classification.flags.vendored {
2060 parts.push("vendored");
2061 }
2062 if classification.flags.documentation {
2063 parts.push("documentation");
2064 }
2065 format!(" {}", detail(&format!("({})", parts.join(", ")), color))
2066 });
2067 let _ = writeln!(
2068 out,
2069 "{:>width$} {}{}",
2070 value,
2071 human_name(&row.path.to_string_lossy(), row.kind, row.ignored, color),
2072 classification
2073 );
2074 }
2075}
2076
2077fn render_text_summary(
2078 out: &mut String,
2079 row: &SummaryRow,
2080 size: SizeMetric,
2081 selected: IgnoredEntries,
2082 color: bool,
2083) {
2084 let _ = writeln!(
2085 out,
2086 "{} {} {}, {} {}{}",
2087 styled_bytes(pick(size, row.bytes, row.allocated), 10, color, false),
2088 human_count(row.files),
2089 plural(row.files, "file", "files"),
2090 human_count(row.dirs),
2091 plural(row.dirs, "directory", "directories"),
2092 ignored_suffix(row.ignored, size, selected, color),
2093 );
2094}
2095
2096#[cfg(test)]
2101fn yaml_scalar(value: &str) -> String {
2102 let mut out = String::new();
2103 crate::emit::write_yaml_scalar(&mut out, value);
2104 out
2105}
2106
2107#[cfg(test)]
2109fn quote(text: &str) -> String {
2110 let mut out = String::with_capacity(text.len() + 2);
2111 crate::emit::write_json_string(&mut out, text);
2112 out
2113}
2114
2115fn generator() -> String {
2117 format!("fdu {}", env!("CARGO_PKG_VERSION"))
2118}
2119
2120pub(super) fn view_header(view: ViewSpec) -> &'static str {
2128 match view {
2129 ViewSpec::List => "LIST",
2130 ViewSpec::Tree => "TREE",
2131 ViewSpec::Types => "TYPES",
2132 ViewSpec::Extensions => "EXTENSIONS",
2133 ViewSpec::Families => "FAMILIES",
2134 ViewSpec::Languages => "LANGUAGES",
2135 ViewSpec::Code => "CODE",
2136 ViewSpec::Documents => "DOCUMENTS",
2137 ViewSpec::Files => "FILES",
2138 ViewSpec::Largest => "LARGEST",
2139 ViewSpec::Recent => "RECENT",
2140 ViewSpec::Summary => "SUMMARY",
2141 }
2142}
2143
2144fn metric_group_label(group: MetricGroup) -> &'static str {
2145 match group {
2146 MetricGroup::Type => "type",
2147 MetricGroup::Family => "family",
2148 }
2149}
2150
2151fn coverage_label(reason: CoverageReason) -> &'static str {
2152 match reason {
2153 CoverageReason::Analyzed => "analyzed",
2154 CoverageReason::Binary => "binary",
2155 CoverageReason::InvalidUtf8 => "invalid_utf8",
2156 CoverageReason::UnsupportedEncoding => "unsupported_encoding",
2157 CoverageReason::Unsupported => "unsupported",
2158 CoverageReason::IoError => "io_error",
2159 CoverageReason::ChangedDuringRead => "changed_during_read",
2160 CoverageReason::TextOnly => "text_only",
2161 }
2162}
2163
2164fn analysis_set_labels(profile: crate::content::AnalysisSet) -> Vec<&'static str> {
2169 profile.labels()
2170}
2171
2172fn source_label(source: ReportSource) -> &'static str {
2174 match source {
2175 ReportSource::ColdScan => "cold_scan",
2176 ReportSource::WarmRevalidate => "warm_revalidate",
2177 ReportSource::CacheOnly => "cache_only",
2178 }
2179}
2180
2181fn tier_source_label(source: Source) -> &'static str {
2182 match source {
2183 Source::Scanned => "scanned",
2184 Source::Revalidated => "revalidated",
2185 Source::JournalScoped => "journal_scoped",
2186 Source::Cached => "cached",
2187 }
2188}
2189
2190fn structural_coverage_label(reason: crate::engine_contract::CoverageReason) -> &'static str {
2191 match reason {
2192 crate::engine_contract::CoverageReason::Building => "building",
2193 crate::engine_contract::CoverageReason::Budget => "budget",
2194 crate::engine_contract::CoverageReason::Cancelled => "cancelled",
2195 crate::engine_contract::CoverageReason::Inaccessible => "inaccessible",
2196 crate::engine_contract::CoverageReason::Failed => "failed",
2197 }
2198}
2199
2200fn issue_kind_label(kind: IssueKind) -> &'static str {
2201 match kind {
2202 IssueKind::Permission => "permission",
2203 IssueKind::Disappeared => "disappeared",
2204 IssueKind::InvalidMetadata => "invalid_metadata",
2205 IssueKind::ResourceBudget => "resource_budget",
2206 IssueKind::ObservationGap => "observation_gap",
2207 IssueKind::ProviderFailure => "provider_failure",
2208 }
2209}
2210
2211fn freshness_label(freshness: Freshness) -> &'static str {
2213 match freshness {
2214 Freshness::Fresh => "fresh",
2215 Freshness::Reconciling => "reconciling",
2216 Freshness::Stale => "stale",
2217 Freshness::Partial => "partial",
2218 }
2219}
2220
2221fn kind_label(kind: EntryKind) -> &'static str {
2223 match kind {
2224 EntryKind::File => "file",
2225 EntryKind::Dir => "dir",
2226 EntryKind::Symlink => "symlink",
2227 EntryKind::Other => "other",
2228 }
2229}
2230
2231fn pick(size: SizeMetric, apparent: u64, allocated: u64) -> u64 {
2233 match size {
2234 SizeMetric::Apparent => apparent,
2235 SizeMetric::Allocated => allocated,
2236 }
2237}
2238
2239fn plural<'a>(count: u64, singular: &'a str, plural: &'a str) -> &'a str {
2241 if count == 1 { singular } else { plural }
2242}
2243
2244fn ratio(part: u64, whole: u64) -> f64 {
2246 if whole == 0 {
2247 return 0.0;
2248 }
2249 #[allow(clippy::cast_precision_loss)]
2250 let share = part as f64 / whole as f64;
2251 share.clamp(0.0, 1.0)
2252}
2253
2254fn bar_cells(part: u64, whole: u64, width: usize) -> usize {
2258 if whole == 0 {
2259 return 0;
2260 }
2261 let numerator = u128::from(part.min(whole)) * width as u128;
2262 usize::try_from((numerator + u128::from(whole) / 2) / u128::from(whole))
2263 .expect("a bounded share fits the supplied width")
2264}
2265
2266fn usage_bar(bytes: u64, total: u64, ignored: Option<u64>, color: bool, width: usize) -> String {
2272 if width == 0 {
2273 return String::new();
2274 }
2275 let filled = bar_cells(bytes, total, width);
2276 if !color {
2277 return format!("{}{}", "█".repeat(filled), "░".repeat(width - filled));
2278 }
2279 if ignored.is_none() {
2280 return format!(
2281 "{}{}",
2282 paint(&"▒".repeat(filled), STYLE_BAR, true),
2283 paint(&"░".repeat(width - filled), STYLE_BAR.dimmed(), true)
2284 );
2285 }
2286 let ignored = bar_cells(ignored.unwrap_or(0), bytes, filled);
2287 format!(
2288 "{}{}{}",
2289 paint(&"█".repeat(filled - ignored), STYLE_BAR, true),
2290 paint(&"▓".repeat(ignored), STYLE_BAR, true),
2291 paint(&"░".repeat(width - filled), STYLE_BAR.dimmed(), true)
2292 )
2293}
2294
2295pub fn human_count(value: u64) -> String {
2301 human_count_u128(u128::from(value))
2302}
2303
2304pub fn human_count_u128(value: u128) -> String {
2309 let digits = value.to_string();
2310 let mut grouped = String::with_capacity(digits.len() + digits.len() / 3);
2311 for (index, byte) in digits.bytes().enumerate() {
2312 if index > 0 && (digits.len() - index) % 3 == 0 {
2313 grouped.push(',');
2314 }
2315 grouped.push(char::from(byte));
2316 }
2317 grouped
2318}
2319
2320pub fn human_bytes(bytes: u64) -> String {
2326 const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
2327 let mut whole = bytes;
2331 let mut remainder = 0u64;
2332 let mut unit = 0;
2333 while whole >= 1024 && unit + 1 < UNITS.len() {
2334 remainder = whole % 1024;
2335 whole /= 1024;
2336 unit += 1;
2337 }
2338 if unit == 0 {
2339 format!("{} B", human_count(bytes))
2340 } else if whole < 10 {
2341 let tenths = (remainder * 10) / 1024;
2342 format!("{}.{tenths} {}", human_count(whole), UNITS[unit])
2343 } else {
2344 format!("{} {}", human_count(whole), UNITS[unit])
2345 }
2346}
2347
2348pub fn is_lossy(path: &Path) -> bool {
2353 path.to_str().is_none()
2354}
2355
2356pub const STREAM_SCHEMA: &str = "fdu.stream/2";
2362
2363pub fn watch_rule(at: std::time::SystemTime) -> String {
2374 format!("──── {} ────", format_rfc3339(at))
2375}
2376
2377pub fn watch_rule_nanos(at_nanos: i64) -> String {
2383 format!("──── {} ────", format_rfc3339_nanos(at_nanos))
2384}
2385
2386#[cfg(feature = "watch")]
2388pub fn render_change(change: &crate::Change, format: Format) -> String {
2389 let kind = match change.kind {
2390 crate::ChangeKind::Upsert => "upsert",
2391 crate::ChangeKind::Remove => "remove",
2392 crate::ChangeKind::Invalidate => "invalidate",
2393 };
2394
2395 if !format.is_machine() {
2396 return format!("{}\t{kind}", change.path.display());
2399 }
2400
2401 match format {
2402 Format::Json | Format::Jsonl => render_change_machine(change, kind, JsonSink::line()),
2403 Format::Yaml => {
2404 format!(
2405 "{}{}",
2406 document_start(format),
2407 render_change_machine(change, kind, YamlSink::new())
2408 )
2409 }
2410 Format::Text | Format::Tree | Format::Paths | Format::Long => {
2411 unreachable!("text returned above")
2412 }
2413 }
2414}
2415
2416#[cfg(feature = "watch")]
2417fn render_change_machine(
2418 change: &crate::Change,
2419 kind: &str,
2420 mut sink: impl Sink<Output = String>,
2421) -> String {
2422 sink.event(Event::BeginMap(Shape::Block));
2423 emit_str_field(&mut sink, "schema", STREAM_SCHEMA);
2424 emit_str_field(&mut sink, "record", "change");
2425 emit_str_field(&mut sink, "op", kind);
2426 emit_path_fields(&mut sink, &change.path);
2427 emit_u64_field(&mut sink, "clock", change.clock);
2428 emit_field(&mut sink, Field::when_set("kind"), change.entry_kind.is_some(), |sink| {
2429 emit_scalar(sink, Scalar::Str(kind_label(change.entry_kind.expect("presence checked"))));
2430 });
2431 emit_field(&mut sink, Field::when_set("bytes"), change.bytes.is_some(), |sink| {
2432 emit_scalar(sink, Scalar::U64(change.bytes.expect("presence checked")));
2433 });
2434 emit_field(&mut sink, Field::when_set("allocated"), change.allocated.is_some(), |sink| {
2435 emit_scalar(sink, Scalar::U64(change.allocated.expect("presence checked")));
2436 });
2437 emit_field(&mut sink, Field::when_set("mtime_ns"), change.mtime_ns.is_some(), |sink| {
2438 emit_scalar(sink, Scalar::I64(change.mtime_ns.expect("presence checked")));
2439 });
2440 emit_field(&mut sink, Field::when_set("ignored"), change.ignored.is_some(), |sink| {
2441 emit_scalar(sink, Scalar::Bool(change.ignored.expect("presence checked")));
2442 });
2443 sink.event(Event::EndMap);
2444 sink.finish()
2445}
2446
2447fn raw_os_identity(value: &std::ffi::OsStr) -> Option<(&'static str, String)> {
2453 if value.to_str().is_some() {
2454 return None;
2455 }
2456
2457 #[cfg(unix)]
2458 {
2459 use std::os::unix::ffi::OsStrExt;
2460
2461 Some(("unix-bytes", hex_bytes(value.as_bytes().iter().copied())))
2462 }
2463
2464 #[cfg(windows)]
2465 {
2466 use std::os::windows::ffi::OsStrExt;
2467
2468 Some(("windows-wtf16le", hex_bytes(value.encode_wide().flat_map(u16::to_le_bytes))))
2469 }
2470
2471 #[cfg(not(any(unix, windows)))]
2472 {
2473 None
2474 }
2475}
2476
2477#[cfg(any(unix, windows))]
2479fn hex_bytes(bytes: impl IntoIterator<Item = u8>) -> String {
2480 let mut out = String::new();
2481 for byte in bytes {
2482 let _ = write!(out, "{byte:02x}");
2483 }
2484 out
2485}
2486
2487pub fn render_cache_status(
2501 statuses: &[crate::CacheStatus],
2502 scope: crate::CacheScope,
2503 format: Format,
2504) -> String {
2505 render_cache_status_with_options(statuses, scope, format, RenderOptions::default())
2506}
2507
2508pub fn render_cache_status_with_options(
2511 statuses: &[crate::CacheStatus],
2512 scope: crate::CacheScope,
2513 format: Format,
2514 options: RenderOptions,
2515) -> String {
2516 match format {
2517 Format::Jsonl => {
2518 let mut sink = JsonSink::line();
2519 sink.event(Event::BeginMap(Shape::Inline));
2520 emit_str_field(&mut sink, "schema", CACHE_SCHEMA);
2521 sink.event(Event::EndMap);
2522 let mut out = sink.finish();
2523 for status in statuses {
2524 out.push('\n');
2525 let row = cache_row(status);
2526 let mut sink = JsonSink::line();
2527 emit_cache_field(&mut sink, &row);
2528 out.push_str(&sink.finish());
2529 }
2530 out
2531 }
2532 Format::Yaml => render_cache_machine(statuses, YamlSink::new()),
2533 Format::Text | Format::Tree | Format::Paths | Format::Long => {
2538 render_cache_status_text(statuses, scope, options.color)
2539 }
2540 Format::Json => render_cache_machine(statuses, JsonSink::pretty()),
2541 }
2542}
2543
2544fn render_cache_machine(
2545 statuses: &[crate::CacheStatus],
2546 mut sink: impl Sink<Output = String>,
2547) -> String {
2548 sink.event(Event::BeginMap(Shape::Block));
2549 emit_str_field(&mut sink, "schema", CACHE_SCHEMA);
2550 emit_field(&mut sink, Field::always("caches"), true, |sink| {
2551 sink.event(Event::BeginSeq(Shape::Block));
2552 for status in statuses {
2553 let row = cache_row(status);
2554 emit_cache_field(sink, &row);
2555 }
2556 sink.event(Event::EndSeq);
2557 });
2558 sink.event(Event::EndMap);
2559 sink.finish()
2560}
2561
2562fn emit_cache_field(sink: &mut impl Sink, field: &CacheField) {
2563 match field {
2564 CacheField::Null => emit_scalar(sink, Scalar::Null),
2565 CacheField::Bool(value) => emit_scalar(sink, Scalar::Bool(*value)),
2566 CacheField::Count(value) => emit_scalar(sink, Scalar::U64(*value)),
2567 CacheField::Text(value) => emit_scalar(sink, Scalar::Str(value)),
2568 CacheField::List(values) => {
2569 sink.event(Event::BeginSeq(Shape::Block));
2570 for value in values {
2571 emit_cache_field(sink, value);
2572 }
2573 sink.event(Event::EndSeq);
2574 }
2575 CacheField::Map(fields) => {
2576 sink.event(Event::BeginMap(Shape::Block));
2577 for (name, value) in fields {
2578 sink.event(Event::Key(name));
2579 emit_cache_field(sink, value);
2580 }
2581 sink.event(Event::EndMap);
2582 }
2583 }
2584}
2585
2586enum CacheField {
2591 Null,
2592 Bool(bool),
2593 Count(u64),
2594 Text(String),
2595 List(Vec<CacheField>),
2596 Map(Vec<(&'static str, CacheField)>),
2597}
2598
2599impl CacheField {
2600 fn count(value: Option<u64>) -> Self {
2601 value.map_or(Self::Null, Self::Count)
2602 }
2603}
2604
2605fn cache_row(status: &crate::CacheStatus) -> CacheField {
2608 use crate::CacheState;
2609
2610 let mut fields = vec![("path", CacheField::Text(status.path.to_string_lossy().into_owned()))];
2611 if let Some((encoding, hex)) = raw_os_identity(status.path.as_os_str()) {
2612 fields.push((
2613 "path_raw",
2614 CacheField::Map(vec![
2615 ("encoding", CacheField::Text(encoding.to_string())),
2616 ("hex", CacheField::Text(hex)),
2617 ]),
2618 ));
2619 }
2620 fields.extend([
2621 ("bytes", CacheField::Count(status.bytes)),
2622 ("state", CacheField::Text(status.state.label().to_string())),
2623 ]);
2624 match &status.state {
2625 CacheState::Current(info) => {
2626 fields.push(("root", CacheField::Text(info.root.to_string_lossy().into_owned())));
2627 if let Some((encoding, hex)) = raw_os_identity(info.root.as_os_str()) {
2628 fields.push((
2629 "root_raw",
2630 CacheField::Map(vec![
2631 ("encoding", CacheField::Text(encoding.to_string())),
2632 ("hex", CacheField::Text(hex)),
2633 ]),
2634 ));
2635 }
2636 fields.push(("entries", CacheField::Count(info.entries)));
2637 fields.push(("identity", snapshot_identity_field(info.identity)));
2638 }
2639 CacheState::Stale(reason) => fields.extend(stale_fields(*reason)),
2640 CacheState::Leftover(kind) => {
2641 fields.push(("leftover_kind", CacheField::Text(kind.label().to_string())));
2642 }
2643 CacheState::Unrecognized | CacheState::Absent => {}
2644 }
2645 fields.push(("content", status.content.as_ref().map_or(CacheField::Null, content_field)));
2648 CacheField::Map(fields)
2649}
2650
2651fn stale_fields(reason: crate::StaleReason) -> [(&'static str, CacheField); 2] {
2653 [
2654 ("stale_reason", CacheField::Text(reason.label().to_string())),
2655 ("format_version", CacheField::count(reason.format_version().map(u64::from))),
2656 ]
2657}
2658
2659fn content_field(content: &crate::ContentStatus) -> CacheField {
2662 use crate::ContentState;
2663
2664 let mut fields = vec![
2665 ("bytes", CacheField::Count(content.bytes)),
2666 ("state", CacheField::Text(content.state.label().to_string())),
2667 ];
2668 match &content.state {
2669 ContentState::Current(info) => {
2670 fields.push(("records", CacheField::Count(info.records)));
2671 fields.push(("identity", content_identity_field(&info.identity)));
2672 }
2673 ContentState::Stale(reason) => fields.extend(stale_fields(*reason)),
2674 }
2675 CacheField::Map(fields)
2676}
2677
2678fn snapshot_identity_field(identity: crate::SnapshotIdentity) -> CacheField {
2681 let ignore_rules = match identity.controls {
2682 crate::ControlTierIdentity::NotObserved => CacheField::Null,
2683 crate::ControlTierIdentity::Observed { limits } => {
2684 let limit = |limit: Option<usize>| {
2685 CacheField::count(limit.map(|limit| u64::try_from(limit).unwrap_or(u64::MAX)))
2686 };
2687 CacheField::Map(vec![(
2688 "limits",
2689 CacheField::Map(vec![
2690 ("budget", limit(limits.budget)),
2691 ("line_limit", limit(limits.line_limit)),
2692 ]),
2693 )])
2694 }
2695 };
2696 CacheField::Map(vec![
2697 ("entries", entry_identity_field(identity.entries)),
2698 ("ignore_rules", ignore_rules),
2699 ])
2700}
2701
2702fn entry_identity_field(identity: crate::EntryTierIdentity) -> CacheField {
2705 let scope = identity.scope;
2706 let depth = scope.max_depth.map(|depth| u64::try_from(depth).unwrap_or(u64::MAX));
2707 CacheField::Map(vec![
2708 ("engine", CacheField::Count(identity.engine)),
2709 ("max_depth", CacheField::count(depth)),
2710 ("follow_symlinks", CacheField::Bool(scope.follow_symlinks)),
2711 ("one_filesystem", CacheField::Bool(scope.one_filesystem)),
2712 ("hidden_fingerprint", CacheField::Count(scope.hidden_fingerprint)),
2713 ("exclude_special", CacheField::Bool(scope.exclude_special)),
2714 ("population", CacheField::Text(scope.population.label().to_string())),
2715 ("control_fingerprint", CacheField::Count(scope.control_fingerprint)),
2716 ("type_rules_fingerprint", CacheField::Count(identity.type_rules_fingerprint)),
2717 ("reducers_fingerprint", CacheField::Count(identity.reducers_fingerprint)),
2718 ])
2719}
2720
2721fn content_identity_field(identity: &crate::ContentTierIdentity) -> CacheField {
2725 let analyze = analysis_set_labels(identity.analysis)
2726 .into_iter()
2727 .map(|label| CacheField::Text(label.to_string()))
2728 .collect();
2729 let analyzers = identity
2730 .provenance
2731 .analyzers
2732 .iter()
2733 .map(|(id, version)| {
2734 CacheField::Map(vec![
2735 ("id", CacheField::Text(id.0.to_string())),
2736 ("version", CacheField::Count(u64::from(version.0))),
2737 ])
2738 })
2739 .collect();
2740 CacheField::Map(vec![
2741 ("entries", entry_identity_field(identity.entries)),
2742 ("analyze", CacheField::List(analyze)),
2743 ("options_fingerprint", CacheField::Count(identity.provenance.options_fingerprint.0)),
2744 ("analyzers", CacheField::List(analyzers)),
2745 ])
2746}
2747
2748fn render_cache_status_text(
2751 statuses: &[crate::CacheStatus],
2752 scope: crate::CacheScope,
2753 color: bool,
2754) -> String {
2755 use crate::{CacheScope, CacheState, LeftoverKind, StaleReason};
2756
2757 let mut lines = Vec::new();
2758 let mut current = 0_usize;
2759 let (mut stale, mut stale_bytes) = (0_usize, 0_u64);
2760 let (mut leftover, mut leftover_bytes, mut staging) = (0_usize, 0_u64, 0_usize);
2761 let (mut unrecognized, mut unrecognized_bytes) = (0_usize, 0_u64);
2762 for status in statuses {
2763 let content_bytes = status.content_bytes().unwrap_or(0);
2764 match &status.state {
2765 CacheState::Current(info) => {
2766 current += 1;
2767 let stale_content = status
2770 .content
2771 .as_ref()
2772 .is_some_and(|content| matches!(content.state, crate::ContentState::Stale(_)));
2773 lines.push(format!(
2774 "{} {} entries, {} metadata, {} {}content {}",
2775 status.path.display(),
2776 human_count(info.entries),
2777 styled_bytes(status.bytes, 0, color, false),
2778 styled_bytes(content_bytes, 0, color, false),
2779 if stale_content { "stale " } else { "" },
2780 info.root.display()
2781 ));
2782 }
2783 CacheState::Stale(reason) => {
2784 stale += 1;
2785 stale_bytes =
2786 stale_bytes.saturating_add(status.bytes).saturating_add(content_bytes);
2787 let why = match reason {
2788 StaleReason::OlderFormat { version } => {
2789 format!("older snapshot format {version}")
2790 }
2791 StaleReason::NewerFormat { version } => {
2792 format!("newer snapshot format {version}")
2793 }
2794 StaleReason::OtherEngine => "written by another fdu version".to_string(),
2795 StaleReason::Unreadable => "unreadable by this build".to_string(),
2796 };
2797 lines.push(format!(
2798 "{} stale {}, {} metadata, {} content",
2799 status.path.display(),
2800 detail(&format!("({why})"), color),
2801 styled_bytes(status.bytes, 0, color, false),
2802 styled_bytes(content_bytes, 0, color, false)
2803 ));
2804 }
2805 CacheState::Leftover(kind) => {
2806 leftover += 1;
2807 leftover_bytes = leftover_bytes.saturating_add(status.bytes);
2808 let what = match kind {
2809 LeftoverKind::StagingTemporary => {
2810 staging += 1;
2811 "staging temporary"
2812 }
2813 LeftoverKind::OrphanedContent => "orphaned content sidecar",
2814 };
2815 lines.push(format!(
2816 "{} leftover {}, {}",
2817 status.path.display(),
2818 detail(&format!("({what})"), color),
2819 styled_bytes(status.bytes, 0, color, false)
2820 ));
2821 }
2822 CacheState::Unrecognized => {
2823 unrecognized += 1;
2824 unrecognized_bytes = unrecognized_bytes.saturating_add(status.bytes);
2825 lines.push(format!(
2826 "{} unrecognized, {}",
2827 status.path.display(),
2828 styled_bytes(status.bytes, 0, color, false)
2829 ));
2830 }
2831 CacheState::Absent => {}
2835 }
2836 }
2837 if lines.is_empty() {
2838 return "No cached snapshots.".to_string();
2839 }
2840
2841 if stale > 0 {
2842 let (subject, object) = if stale == 1 {
2843 ("1 stale snapshot".to_string(), "it")
2844 } else {
2845 (format!("{} stale snapshots", human_count_u128(stale as u128)), "them")
2846 };
2847 let remedy = match scope {
2848 CacheScope::Root => format!("fdu --cache-clear PATH removes {object}"),
2849 CacheScope::All if current == 0 => format!("fdu --cache-clear=all removes {object}"),
2850 CacheScope::All => {
2851 format!("fdu --cache-clear=all removes {object}, along with every current snapshot")
2852 }
2853 };
2854 lines.push(format!(
2855 "{subject} {} cannot be served by this build; {remedy}.",
2856 cache_size_detail(stale_bytes, color)
2857 ));
2858 }
2859 if leftover > 0 {
2860 let (subject, predicate, object) = if leftover == 1 {
2864 ("1 leftover file".to_string(), "is", "it")
2865 } else {
2866 (format!("{} leftover files", human_count_u128(leftover as u128)), "are", "them")
2867 };
2868 let caveat = if staging > 0 {
2872 ", though a staging file waits until it is too old to be a running writer's"
2873 } else {
2874 ""
2875 };
2876 lines.push(format!(
2877 "{subject} {} {predicate} fdu's own, left by an interrupted \
2878 write; fdu --cache-clear=all reclaims {object}{caveat}.",
2879 cache_size_detail(leftover_bytes, color)
2880 ));
2881 }
2882 if unrecognized > 0 {
2883 let (subject, predicate, object) = if unrecognized == 1 {
2884 ("1 unrecognized file".to_string(), "is not an fdu snapshot", "it")
2885 } else {
2886 (
2887 format!("{} unrecognized files", human_count_u128(unrecognized as u128)),
2888 "are not fdu snapshots",
2889 "them",
2890 )
2891 };
2892 lines.push(format!(
2893 "{subject} {} {predicate}, so fdu leaves {object} in place.",
2894 cache_size_detail(unrecognized_bytes, color)
2895 ));
2896 }
2897 lines.join("\n")
2898}
2899
2900fn cache_size_detail(bytes: u64, color: bool) -> String {
2901 format!("{}{}{}", detail("(", color), styled_bytes(bytes, 0, color, true), detail(")", color))
2902}
2903
2904#[cfg(test)]
2905mod tests {
2906 fn render(report: &Report, format: Format, color: bool) -> String {
2907 super::render(report, format, color).expect("compatible report format")
2908 }
2909
2910 use super::*;
2911 use crate::Index;
2912 use crate::engine_contract::{Attrs, Observation, Op, ScanScope};
2913 use crate::query::{Bound, Query, Request, Selection, ShareThreshold};
2914 use std::ffi::OsStr;
2915 use std::path::PathBuf;
2916 use std::process::{Command, Stdio};
2917 use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
2918
2919 struct Provenance {
2920 scan_started_at: Option<SystemTime>,
2921 generated_at: SystemTime,
2922 source: ReportSource,
2923 complete: bool,
2924 errors: Vec<String>,
2925 }
2926
2927 fn report(index: &Index, request: &Request, provenance: &Provenance) -> crate::Result<Report> {
2928 let mut report = crate::query::report(index, request, provenance.generated_at)?;
2929 report.provenance.scan_started_at = provenance.scan_started_at;
2930 report.provenance.source = provenance.source;
2931 report.status.complete = provenance.complete;
2932 report.status.errors = provenance
2933 .errors
2934 .iter()
2935 .cloned()
2936 .map(|message| crate::Issue::provider_failure(None, message))
2937 .collect();
2938 Ok(report)
2939 }
2940
2941 fn attrs(size: u64, mtime_ns: i64) -> Attrs {
2942 Attrs {
2943 size,
2944 allocated: size.div_ceil(512) * 512,
2945 mtime_ns,
2946 ctime_ns: mtime_ns,
2947 inode: 7,
2948 dev: 1,
2949 }
2950 }
2951
2952 fn cache_file(name: &str, bytes: u64, state: crate::CacheState) -> crate::CacheStatus {
2953 let content =
2955 matches!(state, crate::CacheState::Stale(_)).then_some(crate::ContentStatus {
2956 bytes: 5,
2957 state: crate::ContentState::Stale(crate::StaleReason::OlderFormat { version: 4 }),
2958 });
2959 crate::CacheStatus { path: PathBuf::from(name), bytes, content, state }
2960 }
2961
2962 fn small_snapshot_identity() -> crate::SnapshotIdentity {
2964 crate::SnapshotIdentity {
2965 entries: crate::EntryTierIdentity {
2966 engine: 1,
2967 scope: crate::EntryScope {
2968 max_depth: None,
2969 follow_symlinks: false,
2970 one_filesystem: true,
2971 hidden_fingerprint: 2,
2972 exclude_special: false,
2973 population: IgnoredEntries::Include,
2974 control_fingerprint: 0,
2975 },
2976 type_rules_fingerprint: 3,
2977 reducers_fingerprint: 4,
2978 },
2979 controls: crate::ControlTierIdentity::Observed {
2980 limits: crate::control::ControlLimits { budget: Some(10), line_limit: None },
2981 },
2982 }
2983 }
2984
2985 #[test]
2992 fn cache_status_shows_stale_and_unrecognized_files_with_their_remedy() {
2993 use crate::{CacheScope, CacheState, LeftoverKind, StaleReason};
2994
2995 let stale = [
2996 cache_file("a.fdu", 10, CacheState::Stale(StaleReason::OlderFormat { version: 2 })),
2997 cache_file("b.fdu", 20, CacheState::Stale(StaleReason::NewerFormat { version: 99 })),
2998 cache_file("c.fdu", 30, CacheState::Stale(StaleReason::OtherEngine)),
2999 cache_file("d.fdu", 40, CacheState::Stale(StaleReason::Unreadable)),
3000 cache_file("notes.txt", 14, CacheState::Unrecognized),
3001 ];
3002 assert_eq!(
3003 render_cache_status(&stale, CacheScope::All, Format::Text),
3004 "a.fdu stale (older snapshot format 2), 10 B metadata, 5 B content\n\
3005 b.fdu stale (newer snapshot format 99), 20 B metadata, 5 B content\n\
3006 c.fdu stale (written by another fdu version), 30 B metadata, 5 B content\n\
3007 d.fdu stale (unreadable by this build), 40 B metadata, 5 B content\n\
3008 notes.txt unrecognized, 14 B\n\
3009 4 stale snapshots (120 B) cannot be served by this build; \
3010 fdu --cache-clear=all removes them.\n\
3011 1 unrecognized file (14 B) is not an fdu snapshot, so fdu leaves it in place."
3012 );
3013 let colored = render_cache_status_with_options(
3014 &stale,
3015 CacheScope::All,
3016 Format::Text,
3017 RenderOptions { color: true, ..RenderOptions::default() },
3018 );
3019 assert!(colored.contains(&detail("(older snapshot format 2)", true)), "{colored:?}");
3020 assert!(
3021 colored.contains(&format!("4 stale snapshots {} cannot", cache_size_detail(120, true))),
3022 "{colored:?}"
3023 );
3024 assert_eq!(
3025 strip_ansi(&colored),
3026 render_cache_status(&stale, CacheScope::All, Format::Text)
3027 );
3028
3029 let leftovers = [
3031 cache_file(
3032 ".g.fdu.tmp.1.2.3",
3033 60,
3034 CacheState::Leftover(LeftoverKind::StagingTemporary),
3035 ),
3036 cache_file("h.analysis.bin", 70, CacheState::Leftover(LeftoverKind::OrphanedContent)),
3037 ];
3038 assert_eq!(
3039 render_cache_status(&leftovers, CacheScope::All, Format::Text),
3040 ".g.fdu.tmp.1.2.3 leftover (staging temporary), 60 B\n\
3041 h.analysis.bin leftover (orphaned content sidecar), 70 B\n\
3042 2 leftover files (130 B) are fdu's own, left by an interrupted write; \
3043 fdu --cache-clear=all reclaims them, though a staging file waits until it is \
3044 too old to be a running writer's."
3045 );
3046 assert!(render_cache_status(&leftovers[..1], CacheScope::Root, Format::Text).ends_with(
3047 "1 leftover file (60 B) is fdu's own, left by an interrupted write; \
3048 fdu --cache-clear=all reclaims it, though a staging file waits until it \
3049 is too old to be a running writer's."
3050 ));
3051 assert!(render_cache_status(&leftovers[1..], CacheScope::All, Format::Text).ends_with(
3054 "1 leftover file (70 B) is fdu's own, left by an interrupted write; \
3055 fdu --cache-clear=all reclaims it."
3056 ));
3057
3058 let root = [cache_file("a.fdu", 10, CacheState::Stale(StaleReason::OtherEngine))];
3059 assert!(render_cache_status(&root, CacheScope::Root, Format::Text).ends_with(
3060 "1 stale snapshot (15 B) cannot be served by this build; \
3061 fdu --cache-clear PATH removes it."
3062 ));
3063
3064 let current = cache_file(
3065 "e.fdu",
3066 50,
3067 CacheState::Current(crate::SnapshotInfo {
3068 root: PathBuf::from("/tree"),
3069 identity: small_snapshot_identity(),
3070 entries: 3,
3071 }),
3072 );
3073 let mixed = [stale[0].clone(), current, stale[4].clone(), stale[4].clone()];
3074 assert!(render_cache_status(&mixed, CacheScope::All, Format::Text).ends_with(
3075 "e.fdu 3 entries, 50 B metadata, 0 B content /tree\n\
3076 notes.txt unrecognized, 14 B\n\
3077 notes.txt unrecognized, 14 B\n\
3078 1 stale snapshot (15 B) cannot be served by this build; \
3079 fdu --cache-clear=all removes it, along with every current snapshot.\n\
3080 2 unrecognized files (28 B) are not fdu snapshots, so fdu leaves them in place."
3081 ));
3082
3083 let absent = [cache_file("f.fdu", 0, CacheState::Absent)];
3084 assert_eq!(
3085 render_cache_status(&absent, CacheScope::Root, Format::Text),
3086 "No cached snapshots."
3087 );
3088 assert_eq!(
3091 render_cache_status(
3092 &[stale[0].clone(), absent[0].clone(), stale[4].clone(), leftovers[0].clone()],
3093 CacheScope::All,
3094 Format::Jsonl
3095 ),
3096 "{\"schema\": \"fdu.cache/3\"}\n\
3097 {\"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\
3098 {\"path\": \"f.fdu\", \"bytes\": 0, \"state\": \"absent\", \"content\": null}\n\
3099 {\"path\": \"notes.txt\", \"bytes\": 14, \"state\": \"unrecognized\", \"content\": null}\n\
3100 {\"path\": \".g.fdu.tmp.1.2.3\", \"bytes\": 60, \"state\": \"leftover\", \"leftover_kind\": \"staging_temporary\", \"content\": null}"
3101 );
3102 assert_eq!(
3103 render_cache_status(&[], CacheScope::All, Format::Jsonl),
3104 "{\"schema\": \"fdu.cache/3\"}"
3105 );
3106 assert_eq!(
3107 render_cache_status(&[], CacheScope::All, Format::Json),
3108 "{\n \"schema\": \"fdu.cache/3\",\n \"caches\": []\n}\n"
3109 );
3110 assert_eq!(
3113 render_cache_status(&[], CacheScope::All, Format::Yaml),
3114 "schema: fdu.cache/3\ncaches: []\n"
3115 );
3116 assert!(
3117 render_cache_status(&stale[2..3], CacheScope::All, Format::Json)
3118 .starts_with("{\n \"schema\": \"fdu.cache/3\",\n \"caches\": [\n {")
3119 );
3120 assert!(
3121 render_cache_status(&stale[2..3], CacheScope::All, Format::Yaml)
3122 .starts_with("schema: fdu.cache/3\ncaches:\n -\n path: c.fdu")
3123 );
3124 assert!(
3125 render_cache_status(&stale[2..3], CacheScope::All, Format::Yaml).ends_with(
3126 "state: stale\n stale_reason: other_engine\n format_version: null\n \
3127 content:\n bytes: 5\n state: stale\n stale_reason: older_format\n \
3128 format_version: 4\n"
3129 )
3130 );
3131 assert!(render_cache_status(&leftovers[1..], CacheScope::All, Format::Yaml).ends_with(
3132 "state: leftover\n leftover_kind: orphaned_content\n content: null\n"
3133 ));
3134 }
3135
3136 #[test]
3142 fn cache_status_carries_every_stored_tier_identity() {
3143 use crate::{CacheScope, CacheState, ContentInfo, ContentState, ContentStatus};
3144
3145 let snapshot = small_snapshot_identity();
3146 let content = crate::ContentTierIdentity {
3147 entries: snapshot.entries,
3148 analysis: crate::content::AnalysisSet::NONE.with_lines(),
3149 provenance: crate::AnalyzerProvenance {
3150 options_fingerprint: crate::content::OptionsFingerprint(5),
3151 analyzers: vec![(
3152 crate::content::CONTENT_BASIC,
3153 crate::content::AnalyzerVersion(1),
3154 )],
3155 },
3156 };
3157 let status = crate::CacheStatus {
3158 path: PathBuf::from("e.fdu"),
3159 bytes: 50,
3160 content: Some(ContentStatus {
3161 bytes: 9,
3162 state: ContentState::Current(ContentInfo { identity: content, records: 2 }),
3163 }),
3164 state: CacheState::Current(crate::SnapshotInfo {
3165 root: PathBuf::from("/tree"),
3166 identity: snapshot,
3167 entries: 3,
3168 }),
3169 };
3170 let entries = "{\"engine\": 1, \"max_depth\": null, \"follow_symlinks\": false, \
3171 \"one_filesystem\": true, \"hidden_fingerprint\": 2, \"exclude_special\": false, \
3172 \"population\": \"include\", \"control_fingerprint\": 0, \
3173 \"type_rules_fingerprint\": 3, \"reducers_fingerprint\": 4}";
3174 assert_eq!(
3175 render_cache_status(std::slice::from_ref(&status), CacheScope::Root, Format::Jsonl),
3176 format!(
3177 "{{\"schema\": \"fdu.cache/3\"}}\n\
3178 {{\"path\": \"e.fdu\", \"bytes\": 50, \"state\": \"current\", \"root\": \"/tree\", \
3179 \"entries\": 3, \"identity\": {{\"entries\": {entries}, \"ignore_rules\": \
3180 {{\"limits\": {{\"budget\": 10, \"line_limit\": null}}}}}}, \"content\": {{\"bytes\": 9, \
3181 \"state\": \"current\", \"records\": 2, \"identity\": {{\"entries\": {entries}, \
3182 \"analyze\": [\"lines\"], \"options_fingerprint\": 5, \
3183 \"analyzers\": [{{\"id\": \"content-basic-v1\", \"version\": 1}}]}}}}}}"
3184 )
3185 );
3186 let entries = "\n engine: 1\n max_depth: null\n \
3187 follow_symlinks: false\n one_filesystem: true\n \
3188 hidden_fingerprint: 2\n exclude_special: false\n \
3189 population: include\n control_fingerprint: 0\n \
3190 type_rules_fingerprint: 3\n reducers_fingerprint: 4";
3191 assert_eq!(
3192 render_cache_status(std::slice::from_ref(&status), CacheScope::Root, Format::Yaml),
3193 format!(
3194 "schema: fdu.cache/3\ncaches:\n -\n path: e.fdu\n bytes: 50\n state: current\n \
3195 root: /tree\n entries: 3\n identity:\n entries:{}\n \
3196 ignore_rules:\n limits:\n budget: 10\n line_limit: null\n \
3197 content:\n bytes: 9\n state: current\n records: 2\n identity:\n \
3198 entries:{entries}\n analyze:\n - lines\n \
3199 options_fingerprint: 5\n analyzers:\n -\n id: content-basic-v1\n \
3200 version: 1\n",
3201 entries.replace("\n ", "\n")
3202 )
3203 );
3204 let mut large = status.clone();
3205 large.bytes = 60_696_111;
3206 large.content.as_mut().expect("fixture has a content sidecar").bytes = 120_783_062;
3207 assert_eq!(
3208 render_cache_status(std::slice::from_ref(&large), CacheScope::Root, Format::Text),
3209 "e.fdu 3 entries, 57 MiB metadata, 115 MiB content /tree"
3210 );
3211 let colored = render_cache_status_with_options(
3212 std::slice::from_ref(&large),
3213 CacheScope::Root,
3214 Format::Text,
3215 RenderOptions { color: true, ..RenderOptions::default() },
3216 );
3217 assert_eq!(
3218 strip_ansi(&colored),
3219 render_cache_status(&[large], CacheScope::Root, Format::Text)
3220 );
3221 let zero = render_cache_status_with_options(
3222 std::slice::from_ref(&status),
3223 CacheScope::Root,
3224 Format::Text,
3225 RenderOptions { color: true, ..RenderOptions::default() },
3226 );
3227 assert!(!zero.contains("\x1b["), "positive sub-GiB sizes stay unstyled: {zero:?}");
3228 let mut zero_and_gib = status.clone();
3229 zero_and_gib.bytes = 0;
3230 zero_and_gib.content.as_mut().expect("fixture has a content sidecar").bytes = 1 << 30;
3231 let colored = render_cache_status_with_options(
3232 std::slice::from_ref(&zero_and_gib),
3233 CacheScope::Root,
3234 Format::Text,
3235 RenderOptions { color: true, ..RenderOptions::default() },
3236 );
3237 assert!(colored.contains(&paint("0 B", STYLE_DETAIL, true)), "{colored:?}");
3238 assert!(colored.contains(&paint("1.0 GiB", AnsiStyle::new().bold(), true)), "{colored:?}");
3239 assert_eq!(strip_ansi(&colored), "e.fdu 3 entries, 0 B metadata, 1.0 GiB content /tree");
3240 for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3241 assert_eq!(
3242 render_cache_status_with_options(
3243 std::slice::from_ref(&status),
3244 CacheScope::Root,
3245 format,
3246 RenderOptions { color: true, ..RenderOptions::default() },
3247 ),
3248 render_cache_status(std::slice::from_ref(&status), CacheScope::Root, format)
3249 );
3250 }
3251 let stale_content = crate::CacheStatus {
3253 content: Some(ContentStatus {
3254 bytes: 9,
3255 state: ContentState::Stale(crate::StaleReason::OtherEngine),
3256 }),
3257 ..status
3258 };
3259 assert_eq!(
3260 render_cache_status(&[stale_content], CacheScope::Root, Format::Text),
3261 "e.fdu 3 entries, 50 B metadata, 9 B stale content /tree"
3262 );
3263 }
3264
3265 #[test]
3270 fn the_cache_schema_constant_is_the_versioning_promise() {
3271 assert_eq!(CACHE_SCHEMA, "fdu.cache/3");
3272 for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3273 let rendered = render_cache_status(&[], crate::CacheScope::All, format);
3274 assert!(rendered.contains(CACHE_SCHEMA), "{format:?} carries no schema: {rendered}");
3275 }
3276 }
3277
3278 #[cfg(all(unix, feature = "watch"))]
3279 #[test]
3280 fn cache_and_change_rows_preserve_non_unicode_raw_paths() {
3281 use std::ffi::OsString;
3282 use std::os::unix::ffi::OsStringExt;
3283
3284 let path = PathBuf::from(OsString::from_vec(vec![b'n', 0x80]));
3285 let status = crate::CacheStatus {
3286 path: path.clone(),
3287 bytes: 3,
3288 content: None,
3289 state: crate::CacheState::Unrecognized,
3290 };
3291 for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3292 let rendered =
3293 render_cache_status(std::slice::from_ref(&status), crate::CacheScope::All, format);
3294 assert!(rendered.contains("path_raw"), "{format:?}: {rendered}");
3295 assert!(rendered.contains("6e80"), "{format:?}: {rendered}");
3296 }
3297
3298 let change = crate::Change {
3299 path,
3300 kind: crate::ChangeKind::Remove,
3301 entry_kind: None,
3302 bytes: None,
3303 allocated: None,
3304 mtime_ns: None,
3305 ignored: None,
3306 clock: 1,
3307 };
3308 for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3309 let rendered = render_change(&change, format);
3310 assert!(rendered.contains("path_raw"), "{format:?}: {rendered}");
3311 assert!(rendered.contains("6e80"), "{format:?}: {rendered}");
3312 }
3313 }
3314
3315 #[test]
3321 fn a_bound_states_itself_and_states_it_accurately() {
3322 let report = fixture(&[ViewSpec::Files]);
3323 let Section::Files { rows, total, .. } = &report.sections[0] else {
3324 panic!("expected a files section");
3325 };
3326 let full = rows.len();
3327 assert_eq!(*total, full, "an unbounded view drops nothing");
3328 assert!(!render(&report, Format::Text, false).contains("--limit all"), "and says nothing");
3329 assert!(render(&report, Format::Json, false).contains("\"bound\": null"));
3330
3331 let mut query = Query { views: vec![ViewSpec::Files], ..Query::default() };
3333 query.selection.limit = Some(Bound::Limit(1));
3334 let bounded = fixture_for(&query);
3335 let Section::Files { rows, total, .. } = &bounded.sections[0] else {
3336 panic!("expected a files section");
3337 };
3338 assert_eq!(rows.len(), 1);
3339 assert_eq!(*total, full, "the total is what there was, not what was kept");
3340
3341 let text = render(&bounded, Format::Text, false);
3342 assert!(!text.contains("of {full}"), "a single view states its bound in a note: {text}");
3343 assert!(!text.contains("--limit"), "actionable guidance stays out of the body: {text}");
3344 assert!(
3345 report_notes(&bounded)
3346 .contains(&format!("note: display limits: 1 of {full} rows shown")),
3347 "the epilogue states the bound: {:?}",
3348 report_notes(&bounded)
3349 );
3350 assert_eq!(
3351 report_tips(&bounded),
3352 vec!["tip: show more: limit=all"],
3353 "the shared epilogue names the row remedy once"
3354 );
3355 let json = render(&bounded, Format::Json, false);
3356 assert!(json.contains(&format!("\"shown\": 1, \"total\": {full}")), "{json:.200}");
3357 let yaml = render(&bounded, Format::Yaml, false);
3358 assert!(yaml.contains("shown: 1"), "{yaml:.200}");
3359 }
3360
3361 fn strip_ansi(text: &str) -> String {
3368 let mut out = String::with_capacity(text.len());
3369 let mut chars = text.chars();
3370 while let Some(c) = chars.next() {
3371 if c == '\u{1b}' {
3372 for escape in chars.by_ref() {
3373 if escape.is_ascii_alphabetic() {
3374 break;
3375 }
3376 }
3377 } else {
3378 out.push(c);
3379 }
3380 }
3381 out
3382 }
3383
3384 #[test]
3385 fn colour_never_changes_the_layout_of_any_view() {
3386 for view in [
3387 ViewSpec::Tree,
3388 ViewSpec::Extensions,
3389 ViewSpec::Types,
3390 ViewSpec::Families,
3391 ViewSpec::Languages,
3392 ViewSpec::Files,
3393 ViewSpec::Summary,
3394 ] {
3395 let report = fixture(&[view]);
3396 let plain = render(&report, Format::Text, false);
3397 let coloured = render(&report, Format::Text, true);
3398 let styles_a_label = !matches!(view, ViewSpec::Files | ViewSpec::Summary);
3401 assert_eq!(
3402 plain != coloured,
3403 styles_a_label,
3404 "{view:?} disagrees with whether it styles a label"
3405 );
3406 assert_eq!(
3407 strip_ansi(&coloured).replace('·', "░").replace('▒', "█"),
3408 plain,
3409 "{view:?} lays out differently once colour is on"
3410 );
3411 }
3412 }
3413
3414 #[test]
3416 fn a_label_cell_is_measured_on_visible_text() {
3417 let plain = label_cell("md", 6, STYLE_CATEGORY, false);
3418 let coloured = label_cell("md", 6, STYLE_CATEGORY, true);
3419 assert_eq!(plain, "md ", "four spaces of padding");
3420 assert!(coloured.starts_with('\u{1b}'), "the label is styled");
3421 assert!(coloured.ends_with(" "), "and padded by the same four: {coloured:?}");
3422 assert_eq!(label_cell("verylonglabel", 4, STYLE_CATEGORY, false), "verylonglabel");
3424 }
3425
3426 const ALL_TEST_VIEWS: [ViewSpec; 11] = [
3428 ViewSpec::List,
3429 ViewSpec::Tree,
3430 ViewSpec::Types,
3431 ViewSpec::Extensions,
3432 ViewSpec::Families,
3433 ViewSpec::Languages,
3434 ViewSpec::Documents,
3435 ViewSpec::Files,
3436 ViewSpec::Largest,
3437 ViewSpec::Recent,
3438 ViewSpec::Summary,
3439 ];
3440
3441 struct SchemaCheck<S> {
3443 inner: S,
3444 expected: Vec<&'static str>,
3445 next: usize,
3446 depth: usize,
3447 }
3448
3449 impl<S: Sink> SchemaCheck<S> {
3450 fn report(inner: S, lossy: bool, sections: bool) -> Self {
3451 let fields = &REPORT_FIELDS;
3452 let ordered = [
3453 fields.schema,
3454 fields.generator,
3455 fields.root,
3456 fields.root_raw,
3457 fields.age_reference_ns,
3458 fields.request,
3459 fields.status,
3460 fields.provenance,
3461 fields.ignore_rules,
3462 fields.analysis,
3463 fields.reports,
3464 ];
3465 let expected = ordered
3466 .into_iter()
3467 .filter_map(|field| {
3468 let present = match field.presence {
3469 Presence::Always | Presence::Nullable => true,
3470 Presence::WhenLossy => lossy,
3471 Presence::WhenSet => sections,
3472 Presence::WhenAnalyzer(_) => panic!("envelope has no analyzer-owned field"),
3473 };
3474 present.then_some(field.name)
3475 })
3476 .collect();
3477 Self { inner, expected, next: 0, depth: 0 }
3478 }
3479 }
3480
3481 impl<S: Sink> Sink for SchemaCheck<S> {
3482 type Output = S::Output;
3483
3484 fn event(&mut self, event: Event<'_>) {
3485 match event {
3486 Event::BeginMap(_) | Event::BeginSeq(_) => self.depth += 1,
3487 Event::EndMap | Event::EndSeq => self.depth -= 1,
3488 Event::Key(name) if self.depth == 1 => {
3489 assert_eq!(
3490 Some(&name),
3491 self.expected.get(self.next),
3492 "wire field order/presence"
3493 );
3494 self.next += 1;
3495 }
3496 Event::Key(_) | Event::Scalar(_) => {}
3497 }
3498 self.inner.event(event);
3499 }
3500
3501 fn finish(self) -> Self::Output {
3502 assert_eq!(self.depth, 0, "unclosed collection");
3503 assert_eq!(self.next, self.expected.len(), "required field missing");
3504 self.inner.finish()
3505 }
3506 }
3507
3508 #[test]
3509 fn report_walk_obeys_declared_field_order_and_presence_for_every_writer() {
3510 for view in [ViewSpec::Summary, ViewSpec::Documents] {
3511 let report = fixture(&[view]);
3512 for sections in [false, true] {
3513 let mut json = SchemaCheck::report(JsonSink::pretty(), false, sections);
3514 emit_report(&mut json, &report, sections);
3515 assert!(!json.finish().is_empty());
3516 let mut line = SchemaCheck::report(JsonSink::line(), false, sections);
3517 emit_report(&mut line, &report, sections);
3518 assert!(!line.finish().is_empty());
3519 let mut yaml = SchemaCheck::report(YamlSink::new(), false, sections);
3520 emit_report(&mut yaml, &report, sections);
3521 assert!(!yaml.finish().is_empty());
3522 }
3523 }
3524 }
3525
3526 #[test]
3527 #[should_panic(expected = "required field missing")]
3528 fn schema_check_rejects_a_missing_required_field() {
3529 let mut check = SchemaCheck::report(JsonSink::line(), false, true);
3530 check.event(Event::BeginMap(Shape::Inline));
3531 check.event(Event::EndMap);
3532 check.finish();
3533 }
3534
3535 #[test]
3536 fn list_formats_preserve_the_default_tree_and_expose_flat_subtree_metrics() {
3537 let legacy = fixture(&[ViewSpec::Tree]);
3538 let list = fixture(&[ViewSpec::List]);
3539 assert_eq!(
3540 super::render(&legacy, Format::Text, false).expect("tree"),
3541 super::render(&list, Format::Tree, false).expect("list tree")
3542 );
3543 assert!(
3544 super::render(&list, Format::Paths, false).is_err(),
3545 "folding cannot silently become an inventory"
3546 );
3547 let mut rejected = Vec::new();
3548 assert_eq!(
3549 write(&list, Format::Paths, false, &mut rejected)
3550 .expect_err("folded projection")
3551 .kind(),
3552 io::ErrorKind::InvalidInput
3553 );
3554 assert!(rejected.is_empty(), "validate before writing any bytes");
3555 let flat = fixture_for(&Query {
3556 views: vec![ViewSpec::List],
3557 format: Format::Paths,
3558 selection: Selection {
3559 kinds: vec![EntryKind::Dir],
3560 size: SizeMetric::Apparent,
3561 ..Selection::default()
3562 },
3563 ..Query::default()
3564 });
3565 assert_eq!(super::render(&flat, Format::Paths, false).expect("paths"), "src\n");
3566 assert!(super::render(&flat, Format::Long, false).expect("long").contains("100 B"));
3567 assert!(super::render(&flat, Format::Tree, false).is_err());
3568 let Section::Files { rows, .. } = &flat.sections[0] else { panic!("flat list") };
3569 assert_eq!((rows[0].files, rows[0].dirs, rows[0].mtime_ns), (Some(1), Some(0), 10));
3570 assert_eq!(rows[0].complete, Some(true));
3571 assert!(
3572 super::render(&flat, Format::Json, false).expect("json").contains("\"complete\": true")
3573 );
3574 assert_eq!(
3575 rows[0].age_ns,
3576 flat.age_reference_ns.map(|reference| i128::from(reference) - 10)
3577 );
3578 for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3579 let wire = super::render(&flat, format, false).expect("serialization");
3580 assert!(wire.contains("age_reference_ns"));
3581 assert!(wire.contains("age_ns"));
3582 }
3583 assert_eq!(human_age(Some(-1)), "-0s");
3584 assert_eq!(human_age(Some(30 * 86400 * 1_000_000_000)), "30d");
3585 assert_eq!(human_age(None), "unknown");
3586 assert_eq!(flat_path(Path::new("a\nb\tc")), "a\\nb\\tc");
3587 assert_eq!(flat_path(Path::new("d/a\\b")), "d/a\\b");
3590 let mut stale = flat.clone();
3591 stale.provenance.source = ReportSource::CacheOnly;
3592 stale.provenance.freshness = Freshness::Stale;
3593 stale.scope.max_depth = Some(2);
3594 let notes = flat_diagnostics(&stale).join("\n");
3595 assert!(notes.contains("warn: stale answer"), "{notes}");
3596 assert!(!notes.contains("note: cache-only"), "the warning states it once: {notes}");
3597 assert!(notes.contains("note: result stale, complete"), "{notes}");
3598 assert!(notes.contains("scanned to depth 2 only"), "{notes}");
3599 assert_eq!(super::render(&stale, Format::Paths, false).expect("paths"), "src\n");
3600 }
3601
3602 #[test]
3606 fn only_a_cache_only_answer_warns_that_it_is_stale_in_the_surface_vocabulary() {
3607 let tree = Query { views: vec![ViewSpec::Tree], ..Query::default() };
3608 let mut verified = fixture_for(&tree);
3609 for source in [ReportSource::ColdScan, ReportSource::WarmRevalidate] {
3610 verified.provenance.source = source;
3611 assert!(report_warnings(&verified).is_empty(), "{source:?} is verified");
3612 assert!(!diagnostics(&verified).iter().any(|line| line.starts_with("warn:")));
3613 }
3614 for (axes, option) in [
3615 (&crate::query::AxisNames::FLAGS, "--stale-ok"),
3616 (&crate::query::AxisNames::FIELDS, "stale_ok"),
3617 ] {
3618 let mut stale = fixture_for(&Query {
3619 axes,
3620 selection: Selection { depth: Some(Bound::Limit(0)), ..Selection::default() },
3621 ..tree.clone()
3622 });
3623 stale.provenance.source = ReportSource::CacheOnly;
3624 stale.provenance.freshness = Freshness::Stale;
3625 let warning = format!(
3626 "warn: stale answer: served from the snapshot without filesystem verification; \
3627 drop {option} for a fresh answer"
3628 );
3629 assert_eq!(report_warnings(&stale), std::slice::from_ref(&warning));
3630 assert!(!report_notes(&stale).contains(&warning), "a note would be quieted");
3631 let lines = diagnostics(&stale);
3632 let at = lines.iter().position(|line| *line == warning).expect("warning");
3633 assert!(lines[..at].iter().all(|line| line.starts_with("note:")), "{lines:?}");
3634 assert!(lines[at + 1..].iter().all(|line| line.starts_with("tip:")), "{lines:?}");
3635 assert!(lines.first().is_some_and(|line| line.starts_with("note:")), "{lines:?}");
3636 assert!(lines.last().is_some_and(|line| line.starts_with("tip:")), "{lines:?}");
3637 let rendered = super::render(&stale, Format::Text, false).expect("text");
3638 assert!(!rendered.contains("warn:"), "stdout carries only the answer: {rendered}");
3639 }
3640 }
3641
3642 #[test]
3643 fn signed_ages_preserve_exact_endpoints_in_streaming_machine_formats() {
3644 let mut report = fixture_for(&Query {
3645 views: vec![ViewSpec::List],
3646 format: Format::Json,
3647 ..Query::default()
3648 });
3649 for (reference, modified) in [(i64::MAX, i64::MIN), (i64::MIN, i64::MAX)] {
3650 let age = i128::from(reference) - i128::from(modified);
3651 report.age_reference_ns = Some(reference);
3652 let Section::Files { rows, .. } = &mut report.sections[0] else { panic!("flat rows") };
3653 rows[0].mtime_ns = modified;
3654 rows[0].age_ns = Some(age);
3655 for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3656 let rendered = super::render(&report, format, false).expect("machine format");
3657 assert!(rendered.contains(&age.to_string()), "{format:?} lost exact signed age");
3658 let mut streamed = Vec::new();
3659 write(&report, format, false, &mut streamed).expect("streaming writer");
3660 assert_eq!(streamed, rendered.as_bytes());
3661 }
3662 }
3663 }
3664
3665 fn fixture(views: &[ViewSpec]) -> Report {
3666 fixture_for(&Query { views: views.to_vec(), ..Query::default() })
3667 }
3668
3669 fn fixture_for(query: &Query) -> Report {
3670 let mut index = Index::new_with_scope("/root", ScanScope::default());
3671 if query.views.contains(&ViewSpec::Documents) {
3676 index.prepare_content_analysis(crate::content::AnalysisRequest {
3677 profile: crate::content::AnalysisSet::NONE.with_words(),
3678 ..crate::content::AnalysisRequest::default()
3679 });
3680 }
3681 index
3682 .apply(&Observation::new(vec![
3683 Op::Upsert {
3684 path: PathBuf::from("src"),
3685 kind: EntryKind::Dir,
3686 attrs: Attrs::default(),
3687 },
3688 Op::Upsert {
3689 path: PathBuf::from("src/main.rs"),
3690 kind: EntryKind::File,
3691 attrs: attrs(100, 10),
3692 },
3693 Op::Upsert {
3694 path: PathBuf::from("notes.md"),
3695 kind: EntryKind::File,
3696 attrs: attrs(20, 20),
3697 },
3698 ]))
3699 .expect("apply");
3700 report(
3701 &index,
3702 &crate::test_support::read_of(&index, query.clone()),
3703 &Provenance {
3704 scan_started_at: Some(UNIX_EPOCH + Duration::from_secs(1_786_386_151)),
3705 generated_at: UNIX_EPOCH + Duration::from_secs(1_786_386_152),
3706 source: ReportSource::ColdScan,
3707 complete: true,
3708 errors: Vec::new(),
3709 },
3710 )
3711 .expect("report")
3712 }
3713
3714 fn is_view_header_line(line: &str) -> bool {
3719 !line.is_empty() && line.chars().all(|c| c.is_ascii_uppercase())
3720 }
3721
3722 fn is_valid_json(text: &str) -> bool {
3727 let (mut depth, mut in_string, mut escaped) = (0i32, false, false);
3728 for ch in text.chars() {
3729 if in_string {
3730 match ch {
3731 _ if escaped => escaped = false,
3732 '\\' => escaped = true,
3733 '"' => in_string = false,
3734 _ => {}
3735 }
3736 continue;
3737 }
3738 match ch {
3739 '"' => in_string = true,
3740 '{' | '[' => depth += 1,
3741 '}' | ']' => {
3742 depth -= 1;
3743 if depth < 0 {
3744 return false;
3745 }
3746 }
3747 _ => {}
3748 }
3749 }
3750 depth == 0 && !in_string
3751 }
3752
3753 fn compact_json(text: &str) -> String {
3758 let mut compact = String::with_capacity(text.len());
3759 let (mut in_string, mut escaped) = (false, false);
3760 for ch in text.chars() {
3761 if in_string {
3762 compact.push(ch);
3763 match ch {
3764 _ if escaped => escaped = false,
3765 '\\' => escaped = true,
3766 '"' => in_string = false,
3767 _ => {}
3768 }
3769 } else if ch == '"' {
3770 in_string = true;
3771 compact.push(ch);
3772 } else if !ch.is_ascii_whitespace() {
3773 compact.push(ch);
3774 }
3775 }
3776 compact
3777 }
3778
3779 #[test]
3784 fn every_view_renders_in_every_format() {
3785 for view in ALL_TEST_VIEWS {
3786 let report = fixture(&[view]);
3787 for format in [Format::Text, Format::Json, Format::Jsonl, Format::Yaml] {
3788 let rendered = render(&report, format, false);
3789 assert!(!rendered.trim().is_empty(), "{view:?} in {format:?} rendered nothing");
3790 if format != Format::Text {
3791 assert!(
3792 rendered.contains("\"schema\"") || rendered.contains("schema:"),
3793 "{view:?} as {format:?} carries no schema: {rendered:.120}"
3794 );
3795 }
3796 }
3797 }
3798 }
3799
3800 #[test]
3801 fn text_tree_restores_compact_bars_and_keeps_structural_indent_in_the_name_column() {
3802 let apparent = Query {
3805 views: vec![ViewSpec::Tree],
3806 selection: crate::query::Selection {
3807 size: crate::query::SizeMetric::Apparent,
3808 ..crate::query::Selection::default()
3809 },
3810 ..Query::default()
3811 };
3812 let text = render(&fixture_for(&apparent), Format::Text, false);
3813 assert_eq!(
3814 text,
3815 concat!(
3816 "██████████ 100% 120 B . 2 files\n",
3817 "████████░░ 83% 100 B src/ 1 file\n",
3818 "████████░░ 83% 100 B main.rs\n",
3819 "██░░░░░░░░ 17% 20 B notes.md\n",
3820 )
3821 );
3822
3823 let lines: Vec<&str> = text.lines().collect();
3824 assert_eq!(lines[0].find("120 B"), lines[1].find("100 B"));
3825 assert_eq!(lines[0].find('█'), lines[1].find('█'));
3826 }
3827
3828 #[test]
3829 fn language_text_uses_human_names_and_aligns_suffixes_with_color() {
3830 let mut index = Index::new_with_scope("/root", ScanScope::default());
3831 index
3832 .apply(&Observation::new(vec![
3833 Op::Upsert {
3834 path: PathBuf::from("main.cpp"),
3835 kind: EntryKind::File,
3836 attrs: attrs(100, 10),
3837 },
3838 Op::Upsert {
3839 path: PathBuf::from("main.js"),
3840 kind: EntryKind::File,
3841 attrs: attrs(100, 20),
3842 },
3843 ]))
3844 .expect("apply");
3845 let report = report(
3846 &index,
3847 &crate::test_support::read_of(
3848 &index,
3849 Query { views: vec![ViewSpec::Languages], ..Query::default() },
3850 ),
3851 &Provenance {
3852 scan_started_at: None,
3853 generated_at: UNIX_EPOCH,
3854 source: ReportSource::ColdScan,
3855 complete: true,
3856 errors: Vec::new(),
3857 },
3858 )
3859 .expect("report");
3860
3861 let plain = render(&report, Format::Text, false);
3862 assert!(plain.contains("C++"), "{plain}");
3863 assert!(plain.contains("JavaScript"), "{plain}");
3864 let plain_suffixes = plain
3865 .lines()
3866 .map(|line| line.find("1 file").expect("file count suffix"))
3867 .collect::<Vec<_>>();
3868 assert_eq!(plain_suffixes[0], plain_suffixes[1], "{plain}");
3869
3870 let colored = render(&report, Format::Text, true);
3871 let colored_suffixes = colored
3872 .lines()
3873 .map(|line| line.find("1 file").expect("colored file count suffix"))
3874 .collect::<Vec<_>>();
3875 assert_eq!(colored_suffixes[0], colored_suffixes[1], "{colored:?}");
3876
3877 let json = render(&report, Format::Json, false);
3878 assert!(json.contains("\"id\": \"cpp\""), "{json}");
3879 assert!(json.contains("\"id\": \"javascript\""), "{json}");
3880 assert!(!json.contains("\"id\": \"C++\""), "{json}");
3881 assert!(!json.contains("\"id\": \"JavaScript\""), "{json}");
3882 }
3883
3884 #[test]
3885 fn text_labels_a_percentage_that_is_not_a_byte_share() {
3886 let mut languages = fixture(&[ViewSpec::Languages]);
3887 if let Section::Metrics { summary, .. } = &mut languages.sections[0] {
3888 summary.share_metric = ShareMetric::CodeLines;
3889 } else {
3890 panic!("languages should be a metric section");
3891 }
3892 let text = render(&languages, Format::Text, false);
3893 assert!(!text.contains("code lines"), "the result carries no annotation: {text}");
3894 let shares = |report: &Report| {
3895 report_notes(report)
3896 .into_iter()
3897 .filter(|note| note.starts_with("note: percentages are shares of"))
3898 .collect::<Vec<_>>()
3899 };
3900 assert_eq!(shares(&languages), ["note: percentages are shares of code lines"]);
3901
3902 let Section::Metrics { summary, .. } = &mut languages.sections[0] else {
3903 unreachable!("languages should stay a metric section");
3904 };
3905 summary.share_metric = ShareMetric::AllocatedBytes;
3906 assert!(shares(&languages).is_empty(), "byte shares need no note");
3907
3908 let mut both = fixture(&[ViewSpec::Languages, ViewSpec::Documents]);
3909 for (section, metric) in
3910 both.sections.iter_mut().zip([ShareMetric::CodeLines, ShareMetric::DocumentWords])
3911 {
3912 let Section::Metrics { summary, .. } = section else {
3913 panic!("both views should be metric sections");
3914 };
3915 summary.share_metric = metric;
3916 }
3917 assert_eq!(
3918 shares(&both),
3919 ["note: percentages are shares of code lines (LANGUAGES), document words (DOCUMENTS)"],
3920 "one note names each view's denominator"
3921 );
3922
3923 let mut beside_tree = fixture(&[ViewSpec::Tree, ViewSpec::Languages]);
3926 let Section::Metrics { summary, .. } = &mut beside_tree.sections[1] else {
3927 panic!("languages should be a metric section");
3928 };
3929 summary.share_metric = ShareMetric::CodeLines;
3930 assert_eq!(
3931 shares(&beside_tree),
3932 ["note: percentages are shares of code lines (LANGUAGES)"]
3933 );
3934
3935 let mut interleaved =
3938 fixture(&[ViewSpec::Languages, ViewSpec::Documents, ViewSpec::Families]);
3939 for (section, metric) in interleaved.sections.iter_mut().zip([
3940 ShareMetric::CodeLines,
3941 ShareMetric::DocumentWords,
3942 ShareMetric::CodeLines,
3943 ]) {
3944 let Section::Metrics { summary, .. } = section else {
3945 panic!("languages, documents, and families should be metric sections");
3946 };
3947 summary.share_metric = metric;
3948 }
3949 assert_eq!(
3950 shares(&interleaved),
3951 ["note: percentages are shares of code lines (LANGUAGES, FAMILIES), document words \
3952 (DOCUMENTS)"],
3953 "a shared denominator is named once"
3954 );
3955 }
3956
3957 #[test]
3958 fn share_floor_omissions_name_their_section_beside_other_views() {
3959 let mut report = fixture(&[ViewSpec::Types, ViewSpec::Families]);
3960 for section in &mut report.sections {
3961 let Section::Metrics { summary, .. } = section else {
3962 panic!("types and families should be metric sections");
3963 };
3964 summary.share_omitted = 2;
3965 }
3966 let limits = report_notes(&report)
3967 .into_iter()
3968 .filter(|note| note.starts_with("note: display limits:"))
3969 .collect::<Vec<_>>();
3970 assert_eq!(
3971 limits,
3972 ["note: display limits: 2 rows below min share in TYPES, 2 rows below min share in \
3973 FAMILIES"],
3974 "equal counts in different views stay distinct"
3975 );
3976 }
3977
3978 #[test]
3979 fn json_output_is_well_formed_for_every_view() {
3980 for view in [
3981 ViewSpec::Tree,
3982 ViewSpec::Extensions,
3983 ViewSpec::Types,
3984 ViewSpec::Families,
3985 ViewSpec::Languages,
3986 ViewSpec::Documents,
3987 ViewSpec::Files,
3988 ViewSpec::Summary,
3989 ] {
3990 let json = render(&fixture(&[view]), Format::Json, false);
3991 assert!(is_valid_json(&json), "unbalanced JSON for {view:?}:\n{json}");
3992 }
3993 let all = render(
3994 &fixture(&[
3995 ViewSpec::Tree,
3996 ViewSpec::Extensions,
3997 ViewSpec::Types,
3998 ViewSpec::Files,
3999 ViewSpec::Summary,
4000 ]),
4001 Format::Json,
4002 false,
4003 );
4004 assert!(is_valid_json(&all), "unbalanced JSON for a multi-view report:\n{all}");
4005 }
4006
4007 #[test]
4008 fn streaming_machine_writers_match_string_rendering() {
4009 struct Fails;
4010 impl std::io::Write for Fails {
4011 fn write(&mut self, _buffer: &[u8]) -> std::io::Result<usize> {
4012 Err(std::io::Error::other("closed"))
4013 }
4014
4015 fn flush(&mut self) -> std::io::Result<()> {
4016 Ok(())
4017 }
4018 }
4019
4020 let report = fixture(&[
4021 ViewSpec::Tree,
4022 ViewSpec::Extensions,
4023 ViewSpec::Types,
4024 ViewSpec::Files,
4025 ViewSpec::Summary,
4026 ]);
4027 for format in [Format::Json, Format::Jsonl, Format::Yaml] {
4028 let expected = render(&report, format, false);
4029 let mut streamed = Vec::new();
4030 write(&report, format, false, &mut streamed).expect("stream report");
4031 assert_eq!(streamed, expected.as_bytes(), "{format:?} bytes differ");
4032 }
4033
4034 let error = write(&report, Format::Json, false, &mut Fails).expect_err("writer fails");
4035 assert_eq!(error.kind(), std::io::ErrorKind::Other);
4036 }
4037
4038 #[test]
4039 fn streaming_tree_walk_handles_many_siblings_without_collecting_output() {
4040 #[derive(Default)]
4041 struct Count(u64);
4042 impl std::io::Write for Count {
4043 fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
4044 self.0 = self.0.saturating_add(buffer.len() as u64);
4045 Ok(buffer.len())
4046 }
4047
4048 fn flush(&mut self) -> std::io::Result<()> {
4049 Ok(())
4050 }
4051 }
4052
4053 let mut report = fixture(&[ViewSpec::Tree]);
4054 let Section::Tree { root: Some(root), .. } = &mut report.sections[0] else {
4055 panic!("tree fixture must contain a tree");
4056 };
4057 let template = root.children[0].clone();
4058 root.children = (0..10_000)
4059 .map(|index| {
4060 let mut child = template.clone();
4061 child.name = format!("child-{index}");
4062 child.path = PathBuf::from(&child.name);
4063 child
4064 })
4065 .collect();
4066
4067 let mut output = Count::default();
4068 write(&report, Format::Json, false, &mut output).expect("stream wide report");
4069 assert!(output.0 > 1_000_000, "wide fixture must exercise substantial output");
4070 }
4071
4072 #[test]
4073 fn nested_json_separates_siblings_without_a_trailing_comma() {
4074 let mut index = Index::new_with_scope("/root", ScanScope::default());
4078 index
4079 .apply(&Observation::new(vec![
4080 Op::Upsert {
4081 path: PathBuf::from("a"),
4082 kind: EntryKind::Dir,
4083 attrs: Attrs::default(),
4084 },
4085 Op::Upsert {
4086 path: PathBuf::from("b"),
4087 kind: EntryKind::Dir,
4088 attrs: Attrs::default(),
4089 },
4090 Op::Upsert {
4091 path: PathBuf::from("c"),
4092 kind: EntryKind::Dir,
4093 attrs: Attrs::default(),
4094 },
4095 Op::Upsert {
4096 path: PathBuf::from("a/inner"),
4097 kind: EntryKind::Dir,
4098 attrs: Attrs::default(),
4099 },
4100 Op::Upsert {
4101 path: PathBuf::from("a/other"),
4102 kind: EntryKind::Dir,
4103 attrs: Attrs::default(),
4104 },
4105 ]))
4106 .expect("apply");
4107 let report = report(
4108 &index,
4109 &crate::test_support::read_of(
4110 &index,
4111 Query {
4112 selection: Selection {
4113 depth: Some(Bound::All),
4114 min_share: Some(crate::query::ShareThreshold::parse("0%").expect("share")),
4115 ..Selection::default()
4116 },
4117 views: vec![ViewSpec::Tree],
4118 ..Query::default()
4119 },
4120 ),
4121 &Provenance {
4122 scan_started_at: None,
4123 generated_at: UNIX_EPOCH,
4124 source: ReportSource::ColdScan,
4125 complete: true,
4126 errors: Vec::new(),
4127 },
4128 )
4129 .expect("report");
4130
4131 let json = render(&report, Format::Json, false);
4132 assert!(is_valid_json(&json), "{json}");
4133 assert!(!json.contains("}{"), "siblings must be separated:\n{json}");
4134 assert!(!json.contains(",]"), "no trailing comma before a close:\n{json}");
4135 assert!(!json.contains("[,"), "no leading comma after an open:\n{json}");
4136 for name in ["\"a\"", "\"b\"", "\"c\"", "\"inner\"", "\"other\""] {
4138 assert!(json.contains(name), "missing {name} in:\n{json}");
4139 }
4140 }
4141
4142 #[test]
4143 fn jsonl_emits_one_document_per_line() {
4144 let rendered =
4145 render(&fixture(&[ViewSpec::Extensions, ViewSpec::Summary]), Format::Jsonl, false);
4146 let lines: Vec<&str> = rendered.lines().collect();
4147 assert_eq!(lines.len(), 3, "one envelope plus one line per section");
4148 for line in &lines {
4149 assert!(is_valid_json(line), "line is not a JSON document: {line}");
4150 }
4151 assert!(lines[0].contains("\"schema\""), "the envelope carries provenance");
4152 }
4153
4154 #[test]
4155 fn machine_output_carries_the_schema_and_provenance() {
4156 let json = render(&fixture(&[ViewSpec::Summary]), Format::Json, false);
4157 assert!(json.contains("\"schema\": \"fdu.report/10\""));
4158 assert!(json.contains("\"request\": {"));
4159 assert!(json.contains("\"status\": {"));
4160 assert!(json.contains("\"provenance\": {"));
4161 assert!(json.contains("\"source\": \"cold_scan\""));
4162 assert!(json.contains("\"complete\": true"));
4163 assert!(json.contains("\"scan_started_at\": \"2026-08-10T18:22:31.000000000Z\""), "{json}");
4165 assert!(json.contains("\"generated_at\": \"2026-08-10T18:22:32.000000000Z\""), "{json}");
4166 }
4167
4168 #[test]
4169 fn the_schema_constant_is_the_versioning_promise() {
4170 assert_eq!(REPORT_SCHEMA, "fdu.report/10");
4173 assert_eq!(CONTENT_REPORT_SCHEMA, REPORT_SCHEMA);
4174 }
4175
4176 #[test]
4179 fn every_format_states_the_ignore_rules_a_report_could_apply() {
4180 let unobserved = crate::test_support::not_observing_controls();
4181 let mut blind = Index::new_with_scope("/root", unobserved);
4182 blind
4183 .apply(&Observation::new(vec![Op::Upsert {
4184 path: PathBuf::from("a.txt"),
4185 kind: EntryKind::File,
4186 attrs: attrs(1, 1),
4187 }]))
4188 .expect("apply");
4189 let provenance = Provenance {
4190 scan_started_at: None,
4191 generated_at: UNIX_EPOCH,
4192 source: ReportSource::ColdScan,
4193 complete: true,
4194 errors: Vec::new(),
4195 };
4196 let query = Query { views: vec![ViewSpec::Summary], ..Query::default() };
4197 let blind_report =
4198 report(&blind, &crate::test_support::read_of(&blind, query.clone()), &provenance)
4199 .expect("report");
4200 assert!(render(&blind_report, Format::Json, false).contains("\"ignore_rules\": null"));
4201 assert!(render(&blind_report, Format::Yaml, false).contains("\nignore_rules: null\n"));
4202 assert!(blind_report.notes.is_empty());
4203
4204 let mut observed =
4205 Index::new_with_scope("/root", crate::test_support::observing_controls());
4206 let mut long_line = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
4207 long_line.push(b'\n');
4208 observed
4209 .apply(&Observation::new(vec![
4210 Op::Upsert {
4211 path: PathBuf::from("vendor"),
4212 kind: EntryKind::Dir,
4213 attrs: attrs(0, 1),
4214 },
4215 Op::ControlUpsert {
4216 path: PathBuf::from(".gitignore"),
4217 source: b"*.log\n".to_vec(),
4218 },
4219 Op::ControlUpsert { path: PathBuf::from("vendor/.gitignore"), source: long_line },
4220 ]))
4221 .expect("apply");
4222 let refused = Path::new("vendor").join(".gitignore");
4224 let refused = refused.to_string_lossy();
4225 let json = render(
4226 &report(
4227 &observed,
4228 &crate::test_support::read_of(&observed, query.clone()),
4229 &provenance,
4230 )
4231 .expect("report"),
4232 Format::Json,
4233 false,
4234 );
4235 let expected = format!(
4236 "\"ignore_rules\": {{\"limits\": {{\"budget\": 4194304, \"line_limit\": 16384}}, \
4237 \"applied\": 1, \"rules\": 1, \"refused\": 1, \"refusals\": [{{\"path\": {}, \"reason\": \
4238 \"line_limit\"}}]}}",
4239 quote(&refused)
4240 );
4241 assert!(compact_json(&json).contains(&compact_json(&expected)), "{json}");
4242 assert!(json.contains("\"complete\": true"), "a refusal is not an operational partial");
4243 let yaml = render(
4244 &report(
4245 &observed,
4246 &crate::test_support::read_of(&observed, query.clone()),
4247 &provenance,
4248 )
4249 .expect("report"),
4250 Format::Yaml,
4251 false,
4252 );
4253 let expected = format!(
4254 "ignore_rules:\n limits: {{budget: 4194304, line_limit: 16384}}\n applied: 1\n rules: 1\n \
4255 refused: 1\n refusals:\n -\n path: {}\n reason: line_limit\n",
4256 yaml_scalar(&refused)
4257 );
4258 assert!(yaml.contains(&expected), "{yaml}");
4259
4260 let flags = Query { axes: &crate::query::AxisNames::FLAGS, ..query.clone() };
4261 let candidate =
4262 report(&observed, &crate::test_support::read_of(&observed, flags), &provenance)
4263 .expect("report");
4264 let text = render(&candidate, Format::Text, false);
4265 assert!(
4266 !text.contains("note:") && !text.contains("tip:"),
4267 "stdout holds only formatted data"
4268 );
4269 let lines = diagnostics(&candidate);
4270 assert!(
4271 lines.iter().any(|line| line.starts_with("note: ignore classification incomplete:")
4272 && line.contains("vendor"))
4273 );
4274 assert!(lines.last().expect("remedy").contains("--gitignore-line-limit"));
4275 let fields =
4276 report(&observed, &crate::test_support::read_of(&observed, query), &provenance)
4277 .expect("report");
4278 assert!(report_tips(&fields).iter().any(|tip| tip.contains("control_line_limit")));
4279 assert!(!diagnostics(&fields).iter().any(|line| line.contains("--gitignore-line-limit")));
4280 }
4281
4282 #[test]
4286 fn every_format_carries_each_rows_ignored_share() {
4287 let build = |scope: ScanScope| {
4288 let mut index = Index::new_with_scope("/root", scope);
4289 let mut ops = vec![
4290 Op::Upsert {
4291 path: PathBuf::from("dist"),
4292 kind: EntryKind::Dir,
4293 attrs: Attrs::default(),
4294 },
4295 Op::Upsert {
4296 path: PathBuf::from("dist/a.gz"),
4297 kind: EntryKind::File,
4298 attrs: attrs(128, 10),
4299 },
4300 Op::Upsert {
4301 path: PathBuf::from("src"),
4302 kind: EntryKind::Dir,
4303 attrs: Attrs::default(),
4304 },
4305 Op::Upsert {
4306 path: PathBuf::from("src/b.rs"),
4307 kind: EntryKind::File,
4308 attrs: attrs(36, 20),
4309 },
4310 ];
4311 if scope.observes_controls() {
4312 ops.insert(
4313 0,
4314 Op::ControlUpsert {
4315 path: PathBuf::from(".gitignore"),
4316 source: b"dist/\n".to_vec(),
4317 },
4318 );
4319 }
4320 index.apply(&Observation::new(ops)).expect("apply");
4321 index
4322 };
4323 let provenance = Provenance {
4324 scan_started_at: None,
4325 generated_at: UNIX_EPOCH,
4326 source: ReportSource::ColdScan,
4327 complete: true,
4328 errors: Vec::new(),
4329 };
4330 let views = vec![ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions, ViewSpec::Files];
4331 let query = |ignored| Query {
4332 views: views.clone(),
4333 selection: Selection {
4334 ignored,
4335 size: SizeMetric::Apparent,
4336 limit: Some(Bound::All),
4337 ..Selection::default()
4338 },
4339 ..Query::default()
4340 };
4341
4342 let observed = build(crate::test_support::observing_controls());
4343 let text = render(
4344 &report(
4345 &observed,
4346 &crate::test_support::read_of(&observed, query(IgnoredEntries::Include)),
4347 &provenance,
4348 )
4349 .expect("report"),
4350 Format::Text,
4351 false,
4352 );
4353 assert_eq!(
4354 text,
4355 concat!(
4356 "SUMMARY\n",
4357 " 164 B 2 files, 2 directories (128 B gitignored)\n",
4358 "\n",
4359 "TREE\n",
4360 "██████████ 100% 164 B . 2 files (128 B gitignored)\n",
4361 "████████░░ 78% 128 B dist/ 1 file (128 B gitignored)\n",
4362 "████████░░ 78% 128 B a.gz (128 B gitignored)\n",
4363 "██░░░░░░░░ 22% 36 B src/ 1 file\n",
4364 "██░░░░░░░░ 22% 36 B b.rs\n",
4365 "\n",
4366 "EXTENSIONS\n",
4367 " 128 B .gz 1 file (128 B gitignored)\n",
4368 " 36 B .rs 1 file\n",
4369 "\n",
4370 "FILES\n",
4371 "dist\n",
4372 "dist/a.gz\n",
4373 "src\n",
4374 "src/b.rs\n",
4375 )
4376 .replace("dist/a.gz", &format!("dist{}a.gz", std::path::MAIN_SEPARATOR))
4377 .replace("src/b.rs", &format!("src{}b.rs", std::path::MAIN_SEPARATOR))
4378 );
4379 let dirs_only = IgnoredTally { files: 0, dirs: 1, bytes: 0, allocated: 0 };
4381 assert_eq!(
4382 ignored_suffix(Some(dirs_only), SizeMetric::Apparent, IgnoredEntries::Include, false),
4383 ""
4384 );
4385 let large_ignored = IgnoredTally { files: 1, dirs: 0, bytes: 1 << 30, allocated: 1 << 30 };
4386 let suffix = ignored_suffix(
4387 Some(large_ignored),
4388 SizeMetric::Apparent,
4389 IgnoredEntries::Include,
4390 true,
4391 );
4392 assert!(suffix.contains(&paint("1.0 GiB", STYLE_DETAIL.bold(), true)), "{suffix:?}");
4393 assert_eq!(strip_ansi(&suffix), " (1.0 GiB gitignored)");
4394 let only = render(
4395 &report(
4396 &observed,
4397 &crate::test_support::read_of(&observed, query(IgnoredEntries::Only)),
4398 &provenance,
4399 )
4400 .expect("report"),
4401 Format::Text,
4402 false,
4403 );
4404 assert!(only.contains(" 128 B 1 file, 1 directory\n"), "{only}");
4405 assert!(!only.contains("ignored"), "every row is ignored, so none repeats it: {only}");
4406
4407 let json = render(
4408 &report(
4409 &observed,
4410 &crate::test_support::read_of(&observed, query(IgnoredEntries::Include)),
4411 &provenance,
4412 )
4413 .expect("report"),
4414 Format::Json,
4415 false,
4416 );
4417 assert!(is_valid_json(&json), "{json}");
4418 let compact = compact_json(&json);
4419 for expected in [
4420 "\"summary\": {\"files\": 2, \"dirs\": 2, \"bytes\": 164, \"allocated\": 1024, \
4421 \"ignored\": {\"files\": 1, \"dirs\": 1, \"bytes\": 128, \"allocated\": 512}, ",
4422 "\"name\": \"src\", \"path\": \"src\", \"kind\": \"dir\", \"entry_ignored\": false, \"bytes\": 36, \
4423 \"allocated\": 512, \"files\": 1, \"dirs\": 0, \"ignored\": {\"files\": 0, \
4424 \"dirs\": 0, \"bytes\": 0, \"allocated\": 0}, ",
4425 "{\"extension\": \".gz\", \"files\": 1, \"bytes\": 128, \"allocated\": 512, \
4426 \"ignored\": {\"files\": 1, \"bytes\": 128, \"allocated\": 512}}",
4427 "\"kind\": \"dir\", \"bytes\": 128, \"allocated\": 512, \"mtime_ns\": 10, \"files\": 1, \"dirs\": 0, \"complete\": true, \"age_ns\": -10, \"ignored\": true, \"sort_value\": null, \"classification\": null}",
4428 ] {
4429 assert!(compact.contains(&compact_json(expected)), "missing {expected}\nin {json}");
4430 }
4431 let yaml = render(
4432 &report(
4433 &observed,
4434 &crate::test_support::read_of(&observed, query(IgnoredEntries::Include)),
4435 &provenance,
4436 )
4437 .expect("report"),
4438 Format::Yaml,
4439 false,
4440 );
4441 assert!(
4442 yaml.contains(
4443 " allocated: 1024\n ignored: {files: 1, dirs: 1, bytes: 128, \
4444 allocated: 512}\n newest_mtime_ns: 20\n"
4445 ),
4446 "{yaml}"
4447 );
4448 assert!(yaml.contains(" ignored: true\n"), "{yaml}");
4449
4450 let blind = build(crate::test_support::not_observing_controls());
4451 let blind_report = report(
4452 &blind,
4453 &crate::test_support::read_of(&blind, query(IgnoredEntries::Include)),
4454 &provenance,
4455 )
4456 .expect("report");
4457 assert!(!render(&blind_report, Format::Text, false).contains("ignored"));
4458 let json = render(&blind_report, Format::Json, false);
4459 assert!(!json.contains("\"ignored\": {"), "never a zero share for an unread rule: {json}");
4460 assert!(json.contains("\"ignored\": null"), "{json}");
4461 assert!(render(&blind_report, Format::Yaml, false).contains("ignored: null\n"));
4462 }
4463
4464 #[test]
4465 fn every_report_uses_one_schema_and_states_nullable_analysis() {
4466 let metadata = render(&fixture(&[ViewSpec::Tree]), Format::Json, false);
4467 assert!(metadata.contains("\"schema\": \"fdu.report/10\""));
4468 assert!(metadata.contains("\"analysis\": null"));
4469
4470 let metrics = render(&fixture(&[ViewSpec::Types]), Format::Json, false);
4471 assert!(metrics.contains("\"schema\": \"fdu.report/10\""));
4472 assert!(metrics.contains("\"analysis\": null"));
4473 assert!(metrics.contains("\"share\": {\"numerator\":"));
4474 }
4475
4476 #[cfg(feature = "watch")]
4486 #[test]
4487 fn a_stream_record_is_pinned_field_by_field() {
4488 use crate::{Change, ChangeKind};
4489
4490 assert_eq!(STREAM_SCHEMA, "fdu.stream/2");
4491
4492 let upsert = Change {
4493 path: ["src", "main.rs"].iter().collect(),
4494 kind: ChangeKind::Upsert,
4495 entry_kind: Some(EntryKind::File),
4496 bytes: Some(2_048),
4497 allocated: Some(4_096),
4498 mtime_ns: Some(1_700_000_000_000_000_000),
4499 ignored: Some(false),
4500 clock: 7,
4501 };
4502 let path = upsert.path.to_string_lossy().replace('\\', "\\\\");
4505 assert_eq!(
4506 render_change(&upsert, Format::Json),
4507 format!(
4508 "{{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"upsert\", \
4509 \"path\": \"{path}\", \"clock\": 7, \"kind\": \"file\", \"bytes\": 2048, \
4510 \"allocated\": 4096, \"mtime_ns\": 1700000000000000000, \"ignored\": false}}"
4511 )
4512 );
4513
4514 let unclassified = Change { ignored: None, ..upsert.clone() };
4517 assert_eq!(
4518 render_change(&unclassified, Format::Json),
4519 format!(
4520 "{{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"upsert\", \
4521 \"path\": \"{path}\", \"clock\": 7, \"kind\": \"file\", \"bytes\": 2048, \
4522 \"allocated\": 4096, \"mtime_ns\": 1700000000000000000}}"
4523 )
4524 );
4525
4526 let removed = Change {
4530 path: PathBuf::from("gone.txt"),
4531 kind: ChangeKind::Remove,
4532 entry_kind: None,
4533 bytes: None,
4534 allocated: None,
4535 mtime_ns: None,
4536 ignored: None,
4537 clock: 8,
4538 };
4539 assert_eq!(
4540 render_change(&removed, Format::Json),
4541 "{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"remove\", \
4542 \"path\": \"gone.txt\", \"clock\": 8}"
4543 );
4544
4545 let reclassified =
4548 Change { path: PathBuf::from("debug.log"), ignored: Some(true), ..removed.clone() };
4549 assert_eq!(
4550 render_change(&reclassified, Format::Json),
4551 "{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"remove\", \
4552 \"path\": \"debug.log\", \"clock\": 8, \"ignored\": true}"
4553 );
4554
4555 let invalidated = Change {
4558 path: PathBuf::from("subtree"),
4559 kind: ChangeKind::Invalidate,
4560 entry_kind: None,
4561 bytes: None,
4562 allocated: None,
4563 mtime_ns: None,
4564 ignored: None,
4565 clock: 9,
4566 };
4567 assert_eq!(
4568 render_change(&invalidated, Format::Json),
4569 "{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"invalidate\", \
4570 \"path\": \"subtree\", \"clock\": 9}"
4571 );
4572
4573 assert_eq!(render_change(&removed, Format::Text), "gone.txt\tremove");
4575 }
4576
4577 #[test]
4578 fn a_files_view_prints_one_path_per_line_and_nothing_else() {
4579 let text = render(&fixture(&[ViewSpec::Files]), Format::Text, false);
4583 for line in text.lines() {
4584 assert!(!line.contains(' '), "text files output must be bare paths, got {line:?}");
4585 }
4586 let expected: PathBuf = ["src", "main.rs"].iter().collect();
4587 let expected = expected.display().to_string();
4588 assert!(text.lines().any(|line| line == expected), "{text}");
4589 }
4590
4591 #[test]
4592 fn several_views_are_labelled_and_a_lone_view_is_left_bare() {
4593 let text = render(
4596 &fixture(&[ViewSpec::Tree, ViewSpec::Types, ViewSpec::Summary]),
4597 Format::Text,
4598 false,
4599 );
4600 let headers: Vec<&str> = text.lines().filter(|line| is_view_header_line(line)).collect();
4601 assert_eq!(headers, ["TREE", "TYPES", "SUMMARY"], "{text}");
4602
4603 let lines: Vec<&str> = text.lines().collect();
4606 for (index, line) in lines.iter().enumerate() {
4607 if headers.contains(line) {
4608 assert!(
4609 lines.get(index + 1).is_some_and(|next| !next.is_empty()),
4610 "header {line} must sit directly above its rows:\n{text}"
4611 );
4612 if index > 0 {
4613 assert!(
4614 lines[index - 1].is_empty(),
4615 "a blank line must precede header {line}:\n{text}"
4616 );
4617 }
4618 }
4619 }
4620
4621 for view in [ViewSpec::Tree, ViewSpec::Types, ViewSpec::Summary] {
4623 let lone = render(&fixture(&[view]), Format::Text, false);
4624 assert!(
4625 !lone.lines().any(is_view_header_line),
4626 "{view:?} alone must not be labelled:\n{lone}"
4627 );
4628 }
4629 }
4630
4631 #[test]
4632 fn view_headers_are_colorized_only_when_color_is_on() {
4633 let views = [ViewSpec::Tree, ViewSpec::Summary];
4634 let plain = render(&fixture(&views), Format::Text, false);
4635 assert!(plain.starts_with("TREE\n"), "{plain}");
4636 assert!(!plain.contains('\u{1b}'), "uncolored text carries no escapes: {plain:?}");
4637
4638 let colored = render(&fixture(&views), Format::Text, true);
4639 assert!(colored.contains(&paint("TREE", STYLE_HEADING, true)), "{colored:?}");
4640 assert!(colored.contains(&paint("SUMMARY", STYLE_HEADING, true)), "{colored:?}");
4641 }
4642
4643 #[test]
4644 fn human_rows_style_only_their_semantic_spans() {
4645 let ignored = IgnoredTally { files: 1, dirs: 0, bytes: 43, allocated: 43 };
4646 let mut tree = fixture(&[ViewSpec::Tree]);
4647 let Section::Tree { root: Some(root), .. } = &mut tree.sections[0] else { panic!("tree") };
4648 root.name = "a(b)\n界".into();
4649 root.files = 3_508;
4650 root.ignored = Some(ignored);
4651 let plain = render(&tree, Format::Text, false);
4652 let colored = render(&tree, Format::Text, true);
4653 assert!(
4654 colored.contains(&format!(
4655 "{} 3,508 files {}",
4656 human_name("a(b)\n界", EntryKind::Dir, None, true),
4657 detail("(43 B gitignored)", true)
4658 )),
4659 "{colored:?}"
4660 );
4661 assert_eq!(strip_ansi(&colored).replace('·', "░").replace('▒', "█"), plain);
4662 assert!(!colored.contains("a(b)\n界"));
4663
4664 let mut summary = fixture(&[ViewSpec::Summary]);
4665 let Section::Summary(row) = &mut summary.sections[0] else { panic!("summary") };
4666 row.files = 3_508;
4667 row.ignored = Some(ignored);
4668 let colored = render(&summary, Format::Text, true);
4669 assert!(
4670 colored.contains(&format!(
4671 "3,508 files, 1 directory {}",
4672 detail("(43 B gitignored)", true)
4673 )),
4674 "{colored:?}"
4675 );
4676 assert_eq!(strip_ansi(&colored), render(&summary, Format::Text, false));
4677
4678 let mut types = fixture(&[ViewSpec::Extensions]);
4679 let Section::Extensions { rows, .. } = &mut types.sections[0] else { panic!("extensions") };
4680 rows[0].extension = ".(txt)".into();
4681 rows[0].files = 3_508;
4682 rows[0].ignored = Some(ignored);
4683 let colored = render(&types, Format::Text, true);
4684 assert!(colored.contains(&paint(".(txt)", STYLE_CATEGORY, true)), "{colored:?}");
4685 assert!(
4686 colored.contains(&format!("3,508 files {}", detail("(43 B gitignored)", true))),
4687 "{colored:?}"
4688 );
4689 assert_eq!(strip_ansi(&colored), render(&types, Format::Text, false));
4690 }
4691
4692 const TEXT_METRIC_INDENT: usize =
4695 TEXT_SIZE_WIDTH + 2 + TEXT_SHARE_WIDTH + 2 + TEXT_METRIC_LABEL_WIDTH + 1 + 2;
4696
4697 #[test]
4698 fn metric_breakdowns_and_ranked_paths_keep_span_boundaries() {
4699 let mut metrics = fixture(&[ViewSpec::Types]);
4700 let Section::Metrics { summary, .. } = &mut metrics.sections[0] else { panic!("metrics") };
4701 let row = &mut summary.rows[0];
4702 row.files = 1_234;
4703 row.metrics.physical_lines = Some(477_298);
4704 row.metrics.nonblank_lines = Some(439_949);
4705 row.metrics.blank_lines = Some(37_349);
4706 let colored = render(&metrics, Format::Text, true);
4707 assert!(
4708 colored.contains(&format!(
4709 "1,234 files\n{}477,298 lines {}\n",
4710 " ".repeat(TEXT_METRIC_INDENT),
4711 detail("(439,949 nonblank, 37,349 blank)", true)
4712 )),
4713 "{colored:?}"
4714 );
4715 assert_eq!(strip_ansi(&colored), render(&metrics, Format::Text, false));
4716
4717 let mut largest = fixture(&[ViewSpec::Largest]);
4718 let Section::Files { rows, .. } = &mut largest.sections[0] else { panic!("files") };
4719 rows[0].path = PathBuf::from("a(b)\n界.rs");
4720 let colored = render(&largest, Format::Text, true);
4721 assert!(colored.contains(&paint("a(b)\\n界.rs", STYLE_NAME, true)), "{colored:?}");
4722 assert_eq!(strip_ansi(&colored), render(&largest, Format::Text, false));
4723 for format in [Format::Json, Format::Jsonl, Format::Yaml] {
4724 assert!(!render(&largest, format, true).contains('\u{1b}'));
4725 }
4726 }
4727
4728 #[test]
4733 fn grouped_rows_stack_each_further_measure_under_the_file_count() {
4734 for view in [ViewSpec::Types, ViewSpec::Families, ViewSpec::Languages, ViewSpec::Documents]
4735 {
4736 let mut metrics = fixture(&[view]);
4737 let Section::Metrics { summary, .. } = &mut metrics.sections[0] else {
4738 panic!("{view:?} should be a metric section")
4739 };
4740 let row = &mut summary.rows[0];
4741 row.files = 4_064;
4742 row.metrics.physical_lines = Some(834_425);
4743 row.metrics.nonblank_lines = Some(633_499);
4744 row.metrics.blank_lines = Some(200_926);
4745 row.metrics.document_words = Some(4_390_955);
4746 row.generated_files = 2;
4747 row.vendored_files = 3;
4748 row.documentation_files = 4_063;
4749 row.code_coverage = None;
4750 row.words_coverage = None;
4751 row.lines_coverage = std::collections::BTreeMap::from([
4752 (CoverageReason::Analyzed, 3_997),
4753 (CoverageReason::Binary, 60),
4754 (CoverageReason::Unsupported, 7),
4755 ]);
4756 let plain = render(&metrics, Format::Text, false);
4757 let lines = plain.lines().collect::<Vec<_>>();
4758 let at = lines[0].find("4,064 files").expect("the file count ends the first line");
4760 let column = display_width(&lines[0][..at]);
4761 assert!(lines[0].ends_with("4,064 files"), "{plain}");
4762 let indent = " ".repeat(column + 2);
4763 let stacked = [
4764 "834,425 lines (633,499 nonblank, 200,926 blank)",
4765 "4,390,955 words (17,563.8 pages)",
4766 "2 generated",
4767 "3 vendored",
4768 "60 binary",
4769 "7 unsupported",
4770 ]
4771 .map(|measure| format!("{indent}{measure}"));
4772 assert_eq!(lines[1..=stacked.len()], stacked, "{view:?}:\n{plain}");
4773 assert!(plain.lines().all(|line| !line.ends_with(' ')), "{plain:?}");
4774 assert!(!plain.contains("documentation"), "{view:?}:\n{plain}");
4776
4777 let colored = render(&metrics, Format::Text, true);
4778 assert_eq!(strip_ansi(&colored), plain, "color must not move a continuation");
4779 for (measure, breakdown) in [
4780 ("834,425 lines", "(633,499 nonblank, 200,926 blank)"),
4781 ("4,390,955 words", "(17,563.8 pages)"),
4782 ] {
4783 let expected = format!("\n{indent}{measure} {}\n", detail(breakdown, true));
4784 assert!(colored.contains(&expected), "{view:?}: {colored:?}");
4785 }
4786 }
4787
4788 let mut types = fixture(&[ViewSpec::Types]);
4792 let Section::Metrics { summary, .. } = &mut types.sections[0] else { panic!("types") };
4793 assert_eq!(summary.rows.len(), 2, "a measured row and a bare one");
4794 summary.rows[0].files = 12;
4795 summary.rows[0].generated_files = 12;
4796 summary.rows[1].files = 1;
4797 summary.rows[1].documentation_files = 1;
4798 let plain = render(&types, Format::Text, false);
4799 let lines = plain.lines().collect::<Vec<_>>();
4800 assert_eq!(lines.len(), 3, "{plain}");
4801 assert_eq!(lines[1], format!("{}12 generated", " ".repeat(TEXT_METRIC_INDENT)));
4802 assert!(lines[2].ends_with(" 1 file"), "{plain}");
4803 assert_eq!(lines[2].find("1 file"), lines[0].find("12 files"), "{plain}");
4804 let json = render(&types, Format::Json, false);
4805 assert!(json.contains("\"documentation\": 1}"), "machine output keeps it: {json}");
4806 }
4807
4808 #[test]
4809 fn code_population_details_keep_known_and_unknown_contributions_visible() {
4810 use crate::query::{CodeLanguageRow, MetricShare};
4811 let tally = |lines| CodeTally {
4812 source_files: 1,
4813 analyzed_files: 1,
4814 metrics: crate::content::CodeMetrics { code_lines: lines, ..Default::default() },
4815 ..Default::default()
4816 };
4817 let mut overview = CodeOverview {
4818 population: IgnoredEntries::Include,
4819 selected: CodeTally { source_files: 3, analyzed_files: 3, ..tally(110) },
4820 non_ignored: Some(tally(80)),
4821 ignored: Some(tally(20)),
4822 unknown: tally(10),
4823 unclassified_files: 0,
4824 analyzed_languages: 1,
4825 total_languages: 1,
4826 share_omitted: 0,
4827 languages: vec![CodeLanguageRow {
4828 language: "rust".into(),
4829 selected: CodeTally { source_files: 3, analyzed_files: 3, ..tally(110) },
4830 non_ignored: Some(tally(80)),
4831 ignored: Some(tally(20)),
4832 unknown: tally(10),
4833 share: MetricShare { numerator: 110, denominator: 110 },
4834 }],
4835 share_metric: ShareMetric::CodeLines,
4836 };
4837 let mut report = fixture(&[ViewSpec::Summary]);
4838 report.sections = vec![Section::Code(Box::new(overview.clone()))];
4839 let colored = render(&report, Format::Text, true);
4840 assert!(colored.contains(&paint("TOTAL ", AnsiStyle::new().bold(), true)), "{colored:?}");
4841 let colored_total = colored.lines().find(|line| line.contains("TOTAL")).expect("total row");
4842 assert_eq!(colored_total.matches("\x1b[1m").count(), 6, "all primary TOTAL cells are bold");
4843 assert!(colored.contains(&detail("(20 gitignored, 10 unknown)", true)), "{colored:?}");
4844 assert!(!colored.contains("non-gitignored"), "the complement is never repeated");
4845 let plain = strip_ansi(&colored);
4846 assert!(
4847 plain.lines().next().expect("header").contains("Analyzed files Language"),
4848 "{plain}"
4849 );
4850 let total = plain.lines().find(|line| line.contains("TOTAL")).expect("total row");
4851 assert_eq!(
4852 total.split_whitespace().take(6).collect::<Vec<_>>(),
4853 ["110", "100.0%", "0", "0", "3/3", "TOTAL"]
4854 );
4855 assert_eq!(
4856 plain.matches(" 110").count(),
4857 2,
4858 "row and TOTAL retain the same measured total"
4859 );
4860 assert_eq!(strip_ansi(&colored), render(&report, Format::Text, false));
4861 for format in [Format::Json, Format::Jsonl, Format::Yaml] {
4862 assert!(!render(&report, format, true).contains('\u{1b}'));
4863 }
4864 overview.population = IgnoredEntries::Only;
4865 overview.non_ignored = None;
4866 overview.ignored = None;
4867 overview.unknown = CodeTally::default();
4868 overview.languages[0].non_ignored = None;
4869 overview.languages[0].ignored = None;
4870 overview.languages[0].unknown = CodeTally::default();
4871 report.sections = vec![Section::Code(Box::new(overview))];
4872 let text = render(&report, Format::Text, false);
4873 assert!(!text.contains("gitignored") && !text.contains(" unknown"), "{text}");
4874 }
4875
4876 #[test]
4877 fn code_table_keeps_global_total_when_rows_are_bounded_and_marks_unmeasured_values() {
4878 use crate::query::{CodeLanguageRow, MetricShare};
4879 let mut overview = CodeOverview {
4880 population: IgnoredEntries::Include,
4881 selected: CodeTally {
4882 source_files: 2,
4883 analyzed_files: 2,
4884 metrics: crate::content::CodeMetrics { code_lines: 12, ..Default::default() },
4885 ..Default::default()
4886 },
4887 non_ignored: None,
4888 ignored: None,
4889 unknown: CodeTally::default(),
4890 unclassified_files: 0,
4891 analyzed_languages: 1,
4892 total_languages: 1,
4893 share_omitted: 0,
4894 languages: Vec::new(),
4895 share_metric: ShareMetric::CodeLines,
4896 };
4897 let mut report = fixture(&[ViewSpec::Summary]);
4898 report.sections = vec![Section::Code(Box::new(overview.clone()))];
4899 let bounded = render(&report, Format::Text, false);
4900 let total = bounded.lines().find(|line| line.contains("TOTAL")).expect("total row");
4901 assert_eq!(
4902 total.split_whitespace().take(6).collect::<Vec<_>>(),
4903 ["12", "100.0%", "0", "0", "2/2", "TOTAL"]
4904 );
4905 assert!(!bounded.contains("Rust"), "{bounded}");
4906
4907 overview.selected.analyzed_files = 0;
4908 overview.selected.metrics = crate::content::CodeMetrics::default();
4909 overview.selected.coverage.insert(CoverageReason::Unsupported, 2);
4910 overview.analyzed_languages = 0;
4911 overview.non_ignored = Some(CodeTally::default());
4912 overview.ignored = Some(CodeTally::default());
4913 overview.languages = vec![CodeLanguageRow {
4914 language: "rust".into(),
4915 selected: CodeTally { source_files: 2, ..Default::default() },
4916 non_ignored: Some(CodeTally::default()),
4917 ignored: Some(CodeTally::default()),
4918 unknown: CodeTally::default(),
4919 share: MetricShare { numerator: 0, denominator: 0 },
4920 }];
4921 report.sections = vec![Section::Code(Box::new(overview))];
4922 let unmeasured = render(&report, Format::Text, false);
4923 let language = unmeasured.lines().find(|line| line.contains("Rust")).expect("language row");
4924 let total = unmeasured.lines().find(|line| line.contains("TOTAL")).expect("total row");
4925 assert_eq!(
4926 language.split_whitespace().take(6).collect::<Vec<_>>(),
4927 ["—", "—", "—", "—", "0/2", "Rust"]
4928 );
4929 assert_eq!(
4930 total.split_whitespace().take(6).collect::<Vec<_>>(),
4931 ["—", "—", "—", "—", "0/2", "TOTAL"]
4932 );
4933 assert!(!unmeasured.contains("gitignored"), "{unmeasured}");
4934 assert!(!unmeasured.contains("unsupported"), "coverage is a note, not a row: {unmeasured}");
4935 assert!(
4936 report_notes(&report).contains(&"note: not analyzed: 2 unsupported".to_owned()),
4937 "{:?}",
4938 report_notes(&report)
4939 );
4940 assert!(unmeasured.lines().all(|line| line.trim_end() == line), "{unmeasured:?}");
4941 }
4942
4943 #[test]
4944 fn percentage_and_unicode_width_keep_small_values_visible() {
4945 assert_eq!(human_percentage(1, 10_000, 1), "<0.1%");
4946 assert_eq!(human_percentage(1, 10_000, 0), "<1%");
4947 assert_eq!(human_percentage(0, 10_000, 1), "0.0%");
4948 assert_eq!(human_percentage(0, 0, 1), "—");
4949 assert_eq!(label_cell("界", 4, STYLE_CATEGORY, false), "界 ");
4950 assert_eq!(label_cell("e\u{301}", 4, STYLE_CATEGORY, false), "e\u{301} ");
4951 }
4952
4953 #[test]
4954 fn no_machine_format_gains_a_text_header() {
4955 let views = [ViewSpec::Tree, ViewSpec::Types, ViewSpec::Files, ViewSpec::Summary];
4958 for format in [Format::Json, Format::Jsonl, Format::Yaml] {
4959 let rendered = render(&fixture(&views), format, false);
4960 for header in ["TREE", "TYPES", "FILES", "SUMMARY"] {
4961 assert!(!rendered.contains(header), "{format:?} leaked {header}:\n{rendered}");
4962 }
4963 }
4964 }
4965
4966 #[test]
4967 fn every_view_has_a_header_that_matches_its_wire_label() {
4968 for view in [
4972 ViewSpec::Tree,
4973 ViewSpec::Extensions,
4974 ViewSpec::Types,
4975 ViewSpec::Families,
4976 ViewSpec::Languages,
4977 ViewSpec::Documents,
4978 ViewSpec::Files,
4979 ViewSpec::Summary,
4980 ] {
4981 let header = view_header(view);
4982 assert_eq!(header, view.label().to_uppercase(), "{view:?}");
4983 assert!(
4984 !header.is_empty() && header.chars().all(|c| c.is_ascii_uppercase()),
4985 "{view:?}"
4986 );
4987 }
4988 }
4989
4990 #[test]
4991 fn yaml_quotes_only_what_would_be_ambiguous() {
4992 assert_eq!(yaml_scalar("cold_scan"), "cold_scan");
4993 assert_eq!(yaml_scalar("src/main.rs"), "src/main.rs");
4994 assert_eq!(yaml_scalar("true"), "\"true\"");
4996 assert_eq!(yaml_scalar("null"), "\"null\"");
4997 assert_eq!(yaml_scalar("12345"), "\"12345\"");
4998 assert_eq!(yaml_scalar(""), "\"\"");
4999 assert_eq!(yaml_scalar("has space"), "\"has space\"");
5000 assert_eq!(yaml_scalar("-leading-dash"), "\"-leading-dash\"");
5001 }
5002
5003 #[test]
5004 fn json_strings_escape_control_characters_and_quotes() {
5005 assert_eq!(quote("a\"b"), "\"a\\\"b\"");
5006 assert_eq!(quote("a\\b"), "\"a\\\\b\"");
5007 assert_eq!(quote("a\nb"), "\"a\\nb\"");
5008 assert_eq!(quote("a\u{1}b"), "\"a\\u0001b\"");
5009 }
5010
5011 #[test]
5012 fn format_values_parse_and_reject_by_name() {
5013 assert_eq!(Format::parse("json"), Some(Format::Json));
5014 assert_eq!(Format::parse(" YAML "), Some(Format::Yaml));
5015 assert_eq!(Format::parse("xml"), None);
5016 assert_eq!(Format::ALL.len(), 7);
5017 }
5018
5019 #[test]
5020 fn human_bytes_reads_at_scale() {
5021 assert_eq!(human_bytes(0), "0 B");
5022 assert_eq!(human_bytes(512), "512 B");
5023 assert_eq!(human_bytes(999), "999 B");
5024 assert_eq!(human_bytes(1000), "1,000 B");
5025 assert_eq!(human_bytes(1024), "1.0 KiB");
5026 assert_eq!(human_bytes(1024 * 1024 * 20), "20 MiB");
5027 }
5028
5029 #[test]
5030 fn human_counts_share_one_full_width_grouping_policy() {
5031 assert_eq!(human_count(999), "999");
5032 assert_eq!(human_count(1000), "1,000");
5033 assert_eq!(
5034 human_count_u128(u128::MAX),
5035 "340,282,366,920,938,463,463,374,607,431,768,211,455"
5036 );
5037 assert_eq!(human_age(Some(1000 * 86400 * 1_000_000_000)), "1,000d");
5038 }
5039
5040 #[test]
5041 fn bars_are_fixed_at_ten_cells_and_saturate() {
5042 assert_eq!(usage_bar(0, 100, Some(0), false, 10), "░░░░░░░░░░");
5043 assert_eq!(usage_bar(50, 100, Some(0), false, 10), "█████░░░░░");
5044 assert_eq!(usage_bar(200, 100, Some(0), false, 10), "██████████");
5045 assert!((ratio(5, 0) - 0.0).abs() < f64::EPSILON);
5046 assert_eq!(bar_cells(u64::MAX / 2, u64::MAX, 1), 0);
5047 assert_eq!(bar_cells(u64::MAX / 2 + 1, u64::MAX, 1), 1);
5048 }
5049
5050 #[test]
5051 fn human_styles_respect_exact_thresholds_and_directory_identity() {
5052 assert_eq!(styled_bytes(0, 0, true, false), "\x1b[90m0 B\x1b[0m");
5053 assert_eq!(styled_bytes(0, 0, false, false), "0 B");
5054 let gib = 1 << 30;
5055 assert_eq!(styled_bytes(gib - 1, 10, true, false), format!("{:>10}", human_bytes(gib - 1)));
5056 assert_eq!(
5057 styled_bytes(gib, 10, true, false),
5058 paint(" 1.0 GiB", AnsiStyle::new().bold(), true)
5059 );
5060 assert_eq!(styled_bytes(gib, 0, true, true), paint("1.0 GiB", STYLE_DETAIL.bold(), true));
5061 assert_eq!(styled_bytes(gib, 0, false, true), "1.0 GiB");
5062 assert_eq!(percentage_cell(99, 10_000, 0, 5, true), detail(" <1%", true));
5063 assert_eq!(percentage_cell(100, 10_000, 0, 5, true), " 1%");
5064 assert_eq!(human_percentage(u64::MAX / 100, u64::MAX, 0), "<1%");
5065 assert_eq!(human_percentage(u64::MAX / 100 + 1, u64::MAX, 0), "1%");
5066 assert_eq!(human_percentage(u64::MAX / 1_000, u64::MAX, 1), "<0.1%");
5067 for name in [".", ".."] {
5068 assert_eq!(human_name(name, EntryKind::Dir, None, false), name);
5069 }
5070 assert_eq!(
5071 human_name("build", EntryKind::Dir, None, true),
5072 format!("{}{}", paint("build", STYLE_NAME, true), detail("/", true))
5073 );
5074 assert_eq!(human_name("build", EntryKind::File, None, false), "build");
5075 assert_eq!(human_name("build", EntryKind::Dir, None, false), "build/");
5076 }
5077
5078 #[test]
5079 fn only_own_ignored_directories_lose_bold_name_styling() {
5080 for ignored in [None, Some(false), Some(true)] {
5081 let style = if ignored == Some(true) { STYLE_IGNORED_NAME } else { STYLE_NAME };
5082 assert_eq!(
5083 human_name("node_modules", EntryKind::Dir, ignored, true),
5084 format!("{}{}", paint("node_modules", style, true), detail("/", true))
5085 );
5086 assert_eq!(
5087 human_name("file.rs", EntryKind::File, ignored, true),
5088 paint("file.rs", STYLE_NAME, true)
5089 );
5090 assert_eq!(human_name("node_modules", EntryKind::Dir, ignored, false), "node_modules/");
5091 }
5092 }
5093
5094 #[test]
5095 fn colored_bars_partition_selected_usage_and_keep_ten_cells() {
5096 let split = usage_bar(60, 100, Some(20), true, 10);
5097 assert_eq!(
5098 split,
5099 format!(
5100 "{}{}{}",
5101 paint("████", STYLE_BAR, true),
5102 paint("▓▓", STYLE_BAR, true),
5103 paint("░░░░", STYLE_BAR.dimmed(), true)
5104 )
5105 );
5106 assert_eq!(strip_ansi(&split).chars().count(), 10);
5107 assert_eq!(usage_bar(60, 100, Some(20), false, 10), "██████░░░░");
5108 assert_eq!(strip_ansi(&usage_bar(60, 60, Some(60), true, 10)), "▓▓▓▓▓▓▓▓▓▓");
5109 assert_eq!(
5110 usage_bar(60, 60, Some(60), true, 10),
5111 format!(
5112 "{}{}{}",
5113 paint("", STYLE_BAR, true),
5114 paint("▓▓▓▓▓▓▓▓▓▓", STYLE_BAR, true),
5115 paint("", STYLE_BAR.dimmed(), true)
5116 )
5117 );
5118 assert_eq!(strip_ansi(&usage_bar(0, 0, None, true, 10)), "░░░░░░░░░░");
5119 let mostly_ignored = usage_bar(130, 2_120, Some(96), true, 10);
5120 assert_eq!(strip_ansi(&mostly_ignored), "▓░░░░░░░░░");
5121 assert!(mostly_ignored.contains("\x1b[32m▓\x1b[0m"));
5122 assert_eq!(strip_ansi(&usage_bar(130, 2_120, Some(96), true, 20)), "▓░░░░░░░░░░░░░░░░░░░");
5123 assert_eq!(strip_ansi(&usage_bar(130, 2_120, Some(96), true, 100)).matches('▓').count(), 4);
5124 }
5125
5126 #[test]
5127 fn render_options_resize_or_remove_tree_bars_without_changing_data() {
5128 let report = fixture_for(&Query {
5129 views: vec![ViewSpec::Tree],
5130 selection: Selection {
5131 size: SizeMetric::Apparent,
5132 depth: Some(Bound::Limit(0)),
5133 ..Selection::default()
5134 },
5135 ..Query::default()
5136 });
5137 for width in [0, 10, 20] {
5138 let options = RenderOptions { color: false, bar_size: width };
5139 let text = render_with_options(&report, Format::Text, options).expect("tree");
5140 let lines = text.lines().collect::<Vec<_>>();
5141 assert_eq!(lines.len(), 2, "{width}: {text:?}");
5142 for line in &lines {
5143 if width == 0 {
5144 assert!(line.starts_with(" 100%"), "{line:?}");
5145 } else {
5146 assert!(line.starts_with(&"█".repeat(width)), "{line:?}");
5147 assert!(line["█".repeat(width).len()..].starts_with(" 100%"));
5148 }
5149 }
5150 assert!(lines[0].ends_with("120 B . 2 files"), "{text:?}");
5151 assert!(lines[1].ends_with("120 B … and 2 more files"), "{text:?}");
5152 let mut streamed = Vec::new();
5153 write_with_options(&report, Format::Text, options, &mut streamed).expect("stream");
5154 assert_eq!(streamed, text.as_bytes());
5155 }
5156 assert_eq!(
5157 render_with_options(&report, Format::Json, RenderOptions { color: true, bar_size: 20 })
5158 .expect("machine"),
5159 render(&report, Format::Json, false)
5160 );
5161 let excessive = RenderOptions { color: false, bar_size: MAX_BAR_SIZE + 1 };
5162 assert!(
5163 render_with_options(&report, Format::Text, excessive)
5164 .expect_err("tree width must be bounded")
5165 .to_string()
5166 .contains("bar_size")
5167 );
5168 let mut output = Vec::new();
5169 assert_eq!(
5170 write_with_options(&report, Format::Text, excessive, &mut output)
5171 .expect_err("streaming tree width must be bounded")
5172 .kind(),
5173 io::ErrorKind::InvalidInput
5174 );
5175 assert!(output.is_empty());
5176 let summary = fixture(&[ViewSpec::Summary]);
5177 assert_eq!(
5178 render_with_options(&summary, Format::Text, excessive).expect("no tree bar"),
5179 render(&summary, Format::Text, false)
5180 );
5181 }
5182
5183 const DEEP_RENDER_CHILD_ENV: &str = "FDU_DEEP_RENDER_CHILD";
5184 const DEEP_RENDER_DEPTH: usize = 1_024;
5185 const DEEP_REPORT_STACK_BYTES: usize = 128 * 1_024;
5186 const DEEP_RENDER_STACK_BYTES: usize = 64 * 1_024;
5187
5188 #[test]
5195 fn deep_rendering_is_stack_safe() {
5196 if std::env::var_os(DEEP_RENDER_CHILD_ENV).is_some() {
5197 run_deep_render_child();
5198 return;
5199 }
5200
5201 let started = Instant::now();
5202 let mut child = Command::new(std::env::current_exe().expect("current test executable"))
5203 .args(["--exact", DEEP_RENDER_TEST_PATH, "--nocapture"])
5204 .env(DEEP_RENDER_CHILD_ENV, "1")
5205 .stdin(Stdio::null())
5206 .stdout(Stdio::piped())
5207 .stderr(Stdio::piped())
5208 .spawn()
5209 .expect("spawn deep-render child");
5210 let stdout = drain_pipe(child.stdout.take().expect("piped stdout"));
5211 let stderr = drain_pipe(child.stderr.take().expect("piped stderr"));
5212 let status = loop {
5213 if let Some(status) = child.try_wait().expect("poll deep-render child") {
5214 break Some(status);
5215 }
5216 if started.elapsed() >= DEEP_RENDER_CHILD_TIMEOUT {
5217 break None;
5218 }
5219 std::thread::sleep(Duration::from_millis(50));
5220 };
5221 let Some(status) = status else {
5222 let _ = child.kill();
5227 let reaped = child.wait().expect("reap the killed deep-render child");
5228 panic!(
5229 "deep-render child still running after {DEEP_RENDER_CHILD_TIMEOUT:?}; killed at \
5230 {:?} ({reaped}). The last `deep-render phase:` line is where it stalled.\n\
5231 stdout:\n{}\nstderr:\n{}",
5232 started.elapsed(),
5233 stdout.join().expect("stdout reader"),
5234 stderr.join().expect("stderr reader")
5235 );
5236 };
5237 let stdout = stdout.join().expect("stdout reader");
5238 let stderr = stderr.join().expect("stderr reader");
5239 assert!(
5240 status.success(),
5241 "deep renderer failed in child process\nstdout:\n{stdout}\nstderr:\n{stderr}"
5242 );
5243
5244 assert!(
5249 stdout.contains("1 passed"),
5250 "the child must actually run the deep render, not filter it away\nstdout:\n{stdout}"
5251 );
5252 }
5253
5254 const DEEP_RENDER_TEST_PATH: &str = "report_format::tests::deep_rendering_is_stack_safe";
5258
5259 const DEEP_RENDER_CHILD_TIMEOUT: Duration = Duration::from_secs(300);
5268
5269 fn drain_pipe(mut pipe: impl io::Read + Send + 'static) -> std::thread::JoinHandle<String> {
5275 std::thread::spawn(move || {
5276 let mut bytes = Vec::new();
5277 let _ = pipe.read_to_end(&mut bytes);
5279 String::from_utf8_lossy(&bytes).into_owned()
5280 })
5281 }
5282
5283 fn run_deep_render_child() {
5284 eprintln!("deep-render phase: fixture");
5290 let mut index = crate::Index::new("/fixture");
5291 let mut path = PathBuf::new();
5292 for depth in 0..DEEP_RENDER_DEPTH {
5293 path.push("d");
5294 index.apply_ok(&crate::Observation::new(vec![crate::Op::Upsert {
5295 path: path.clone(),
5296 kind: EntryKind::Dir,
5297 attrs: crate::Attrs {
5298 mtime_ns: i64::try_from(depth).expect("fixture depth fits i64"),
5299 ..Default::default()
5300 },
5301 }]));
5302 }
5303 index.set_initial_freshness(false);
5304
5305 let report = std::thread::Builder::new()
5306 .name("deep-report".to_string())
5307 .stack_size(DEEP_REPORT_STACK_BYTES)
5308 .spawn(move || {
5309 let query = Query {
5310 selection: Selection {
5311 depth: Some(Bound::All),
5312 breadth: Some(Bound::All),
5313 limit: Some(Bound::All),
5314 min_share: Some(ShareThreshold::parse("0%").expect("zero share is valid")),
5315 ..Selection::default()
5316 },
5317 views: vec![ViewSpec::Tree],
5318 ..Query::default()
5319 };
5320 let provenance = Provenance {
5321 scan_started_at: None,
5322 generated_at: SystemTime::UNIX_EPOCH,
5323 source: ReportSource::ColdScan,
5324 complete: true,
5325 errors: Vec::new(),
5326 };
5327 eprintln!("deep-render phase: request");
5328 let request = crate::test_support::read_of(&index, query);
5329 eprintln!("deep-render phase: report");
5330 let report = report(&index, &request, &provenance).expect("report");
5331 eprintln!("deep-render phase: verify tree");
5332 let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
5333 panic!("expected a tree section with a root")
5334 };
5335 assert!(omissions.is_empty(), "the root must not be omitted");
5336 let mut nodes = 0;
5337 let mut pending = vec![root.as_ref()];
5338 while let Some(node) = pending.pop() {
5339 nodes += 1;
5340 assert!(node.omissions.is_empty(), "no branch may be omitted: {:?}", node.path);
5341 pending.extend(node.children.iter());
5342 }
5343 assert_eq!(nodes, DEEP_RENDER_DEPTH + 1, "the test must reach every directory");
5344 report
5345 })
5346 .expect("spawn deep-report thread")
5347 .join()
5348 .expect("deep-report thread");
5349
5350 std::thread::Builder::new()
5351 .name("deep-render".to_string())
5352 .stack_size(DEEP_RENDER_STACK_BYTES)
5353 .spawn(move || {
5354 for format in [Format::Text, Format::Json, Format::Jsonl, Format::Yaml] {
5355 eprintln!("deep-render phase: render {format:?}");
5356 let rendered = render(&report, format, false);
5357 assert!(!rendered.is_empty(), "{format:?} rendered nothing for a deep tree");
5358 if format != Format::Text {
5359 eprintln!("deep-render phase: stream {format:?}");
5360 let mut streamed = Vec::new();
5361 write(&report, format, false, &mut streamed).expect("stream deep report");
5362 assert_eq!(streamed, rendered.as_bytes(), "{format:?} bytes differ");
5363 }
5364 }
5365 eprintln!("deep-render phase: drop");
5366 drop(report);
5367 eprintln!("deep-render phase: complete");
5368 })
5369 .expect("spawn deep-render thread")
5370 .join()
5371 .expect("deep-render thread");
5372 }
5373
5374 fn assert_json_preserves_raw_identity(
5383 root: PathBuf,
5384 first: &OsStr,
5385 second: &OsStr,
5386 encoding: &str,
5387 root_hex: &str,
5388 first_hex: &str,
5389 second_hex: &str,
5390 ) {
5391 assert_eq!(first.to_string_lossy(), second.to_string_lossy());
5394
5395 let mut index = crate::Index::new(root);
5396 index.apply_ok(&crate::Observation::new(vec![
5397 crate::Op::Upsert {
5398 path: PathBuf::from(first),
5399 kind: EntryKind::File,
5400 attrs: crate::Attrs { size: 1, allocated: 1, ..Default::default() },
5401 },
5402 crate::Op::Upsert {
5403 path: PathBuf::from(second),
5404 kind: EntryKind::File,
5405 attrs: crate::Attrs { size: 1, allocated: 1, ..Default::default() },
5406 },
5407 ]));
5408 index.set_initial_freshness(false);
5409
5410 let query = crate::query::Query {
5414 views: vec![ViewSpec::Files],
5415 selection: Selection { limit: Some(crate::query::Bound::All), ..Selection::default() },
5416 ..crate::query::Query::default()
5417 };
5418 let provenance = Provenance {
5419 scan_started_at: None,
5420 generated_at: std::time::UNIX_EPOCH,
5421 source: ReportSource::ColdScan,
5422 complete: true,
5423 errors: Vec::new(),
5424 };
5425 let files_report =
5426 report(&index, &crate::test_support::read_of(&index, query.clone()), &provenance)
5427 .expect("report");
5428 let rendered = render(&files_report, Format::Json, false);
5429 let mut checked = SchemaCheck::report(JsonSink::pretty(), true, true);
5430 emit_report(&mut checked, &files_report, true);
5431 assert_eq!(checked.finish(), rendered);
5432
5433 let lossy = first.to_string_lossy();
5434 assert_eq!(
5435 rendered.matches(&format!("\"{lossy}\"")).count(),
5436 2,
5437 "both names render the same lossy text: {rendered}"
5438 );
5439 assert!(
5440 rendered.contains(&format!(
5441 "\"root_raw\": {{\"encoding\": \"{encoding}\", \"hex\": \"{root_hex}\"}}"
5442 )),
5443 "{rendered}"
5444 );
5445
5446 for hex in [first_hex, second_hex] {
5452 let row = format!(
5453 "{{\"path\": \"{lossy}\", \"path_raw\": {{\"encoding\": \"{encoding}\", \"hex\": \"{hex}\"}}, \
5454 \"kind\": \"file\", \"bytes\": 1, \"allocated\": 1, \"mtime_ns\": 0, \
5455 \"files\": null, \"dirs\": null, \"complete\": null, \"age_ns\": 0, \"ignored\": false, \"sort_value\": null, \
5456 \"classification\": {{\"file_type\": \"unknown\", \"family\": \"unknown\", \"source\": \"unknown\", \"confidence\": \"heuristic\", \
5457 \"flags\": {{\"generated\": false, \"vendored\": false, \"documentation\": false}}}}}}"
5458 );
5459 assert!(
5460 compact_json(&rendered).contains(&compact_json(&row)),
5461 "a name that is not valid Unicode must carry its raw bytes in a well-formed \
5462 row.\nexpected: {row}\nrendered: {rendered}"
5463 );
5464 }
5465
5466 assert!(
5469 !rendered.contains(", ,") && !rendered.contains(",,"),
5470 "duplicated separator in machine output: {rendered}"
5471 );
5472
5473 let mut dirs = crate::Index::new(PathBuf::from("/tree-fixture"));
5478 dirs.apply_ok(&crate::Observation::new(vec![
5479 crate::Op::Upsert {
5480 path: PathBuf::from(first),
5481 kind: EntryKind::Dir,
5482 attrs: crate::Attrs { size: 0, allocated: 0, ..Default::default() },
5483 },
5484 crate::Op::Upsert {
5485 path: PathBuf::from(first).join("inside.txt"),
5486 kind: EntryKind::File,
5487 attrs: crate::Attrs { size: 1, allocated: 1, ..Default::default() },
5488 },
5489 ]));
5490 dirs.set_initial_freshness(false);
5491 let tree_query = crate::query::Query {
5492 views: vec![ViewSpec::Tree],
5493 selection: Selection {
5494 depth: Some(crate::query::Bound::All),
5495 limit: Some(crate::query::Bound::All),
5496 ..Selection::default()
5497 },
5498 ..crate::query::Query::default()
5499 };
5500 let tree =
5501 report(&dirs, &crate::test_support::read_of(&dirs, tree_query.clone()), &provenance)
5502 .expect("report");
5503 let tree_rendered = render(&tree, Format::Json, false);
5504 assert!(
5505 compact_json(&tree_rendered).contains(&compact_json(&format!(
5506 ", \"path_raw\": {{\"encoding\": \"{encoding}\", \"hex\": \"{first_hex}\"}}, \"kind\":"
5507 ))),
5508 "the tree view must carry raw identity in a well-formed node: {tree_rendered}"
5509 );
5510 assert!(
5511 !tree_rendered.contains(", ,") && !tree_rendered.contains(",,"),
5512 "duplicated separator in tree output: {tree_rendered}"
5513 );
5514 }
5515
5516 #[cfg(unix)]
5517 #[test]
5518 fn json_preserves_distinct_non_unicode_unix_names() {
5519 use std::ffi::OsString;
5520 use std::os::unix::ffi::OsStringExt;
5521
5522 assert_json_preserves_raw_identity(
5523 PathBuf::from(OsString::from_vec(vec![b'/', 0x80])),
5524 &OsString::from_vec(vec![b'n', 0x80]),
5525 &OsString::from_vec(vec![b'n', 0x81]),
5526 "unix-bytes",
5527 "2f80",
5528 "6e80",
5529 "6e81",
5530 );
5531 }
5532
5533 #[cfg(windows)]
5534 #[test]
5535 fn json_preserves_distinct_non_unicode_windows_names() {
5536 use std::ffi::OsString;
5537 use std::os::windows::ffi::OsStringExt;
5538
5539 assert_json_preserves_raw_identity(
5540 PathBuf::from(OsString::from_wide(&[u16::from(b'R'), u16::from(b':'), 0xd800])),
5541 &OsString::from_wide(&[u16::from(b'n'), 0xd800]),
5542 &OsString::from_wide(&[u16::from(b'n'), 0xd801]),
5543 "windows-wtf16le",
5544 "52003a0000d8",
5545 "6e0000d8",
5546 "6e0001d8",
5547 );
5548 }
5549}