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