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 }
1880}
1881
1882fn ignored_suffix(
1894 ignored: Option<IgnoredTally>,
1895 size: SizeMetric,
1896 selected: IgnoredEntries,
1897 color: bool,
1898) -> String {
1899 let shown = match selected {
1900 IgnoredEntries::Include | IgnoredEntries::Exclude => {
1901 ignored.filter(|share| share.files > 0)
1902 }
1903 IgnoredEntries::Only => None,
1904 };
1905 shown.map_or_else(String::new, |share| {
1906 let bytes = pick(size, share.bytes, share.allocated);
1907 if bytes < 1 << 30 || !color {
1908 format!(" {}", detail(&format!("({} gitignored)", human_bytes(bytes)), color))
1909 } else {
1910 format!(
1911 " {}{}{}",
1912 detail("(", color),
1913 styled_bytes(bytes, 0, color, true),
1914 detail(" gitignored)", color)
1915 )
1916 }
1917 })
1918}
1919
1920fn render_text_tree(
1924 out: &mut String,
1925 root: Option<&TreeNode>,
1926 omissions: &[crate::query::TreeOmission],
1927 _limits: &crate::query::TreeDisplayLimits,
1928 size: SizeMetric,
1929 selected: IgnoredEntries,
1930 options: RenderOptions,
1931) {
1932 let RenderOptions { color, bar_size } = options;
1933 let hidden = crate::query::TreeRemainder::from_tree(root, omissions);
1934 if let Some(root) = root {
1935 let grand = pick(size, root.bytes, root.allocated);
1936 let mut stack = vec![(root, 0)];
1937 while let Some((node, depth)) = stack.pop() {
1938 let bytes = pick(size, node.bytes, node.allocated);
1939 let indent = " ".repeat(depth);
1940 let count = if node.kind == EntryKind::File {
1941 String::new()
1942 } else {
1943 format!(" {} {}", human_count(node.files), plural(node.files, "file", "files"))
1944 };
1945 let bar_prefix = if bar_size == 0 {
1946 String::new()
1947 } else {
1948 format!(
1949 "{} ",
1950 usage_bar(
1951 bytes,
1952 grand,
1953 node.ignored.map(|value| pick(size, value.bytes, value.allocated)),
1954 color,
1955 bar_size,
1956 )
1957 )
1958 };
1959 let _ = writeln!(
1960 out,
1961 "{bar_prefix}{} {} {indent}{}{}{}",
1962 percentage_cell(bytes, grand, 0, 5, color),
1963 styled_bytes(bytes, 10, color, false),
1964 human_name(&node.name, node.kind, node.entry_ignored, color),
1965 count,
1966 ignored_suffix(node.ignored, size, selected, color),
1967 );
1968 stack.extend(node.children.iter().rev().map(|child| (child, depth + 1)));
1969 }
1970 }
1971 if let Some(hidden) = hidden {
1974 let grand = root.map(|node| pick(size, node.bytes, node.allocated));
1975 render_tree_remainder(out, &hidden, grand, usize::from(root.is_some()), size, options);
1976 }
1977}
1978
1979fn render_tree_remainder(
1981 out: &mut String,
1982 remainder: &crate::query::TreeRemainder,
1983 grand: Option<u64>,
1984 depth: usize,
1985 size: SizeMetric,
1986 options: RenderOptions,
1987) {
1988 let RenderOptions { color, bar_size } = options;
1989 let bytes = match size {
1990 SizeMetric::Apparent => remainder.bytes,
1991 SizeMetric::Allocated => remainder.allocated,
1992 };
1993 let measure = bytes.map_or_else(
1994 || detail(&format!("{:>10}", "unknown"), color),
1995 |bytes| styled_bytes(bytes, 10, color, false),
1996 );
1997 let (usage_bar, percentage) = match bytes.zip(grand.or(bytes)) {
2000 Some((bytes, grand)) => (
2001 usage_bar(
2002 bytes,
2003 grand,
2004 remainder.ignored.map(|value| pick(size, value.bytes, value.allocated)),
2005 color,
2006 bar_size,
2007 ),
2008 percentage_cell(bytes, grand, 0, 5, color),
2009 ),
2010 None => (" ".repeat(bar_size), detail(&format!("{:>5}", "—"), color)),
2011 };
2012 let files = remainder.files.map_or_else(
2013 || format!("more files {}", detail("(count unknown)", color)),
2014 |files| format!("{} more {}", human_count(files), plural(files, "file", "files")),
2015 );
2016 let indent = " ".repeat(depth);
2017 let note = format!("{} {files}", detail(&format!("{indent}… and"), color));
2018 let bar_prefix = if bar_size == 0 { String::new() } else { format!("{usage_bar} ") };
2019 let _ = writeln!(out, "{bar_prefix}{percentage} {measure} {note}");
2020}
2021
2022fn render_text_types(
2024 out: &mut String,
2025 rows: &[TypeRow],
2026 size: SizeMetric,
2027 selected: IgnoredEntries,
2028 color: bool,
2029) {
2030 let width = label_width(rows.iter().map(|row| row.extension.as_str()), TEXT_TYPE_LABEL_WIDTH);
2031 for row in rows {
2032 let _ = writeln!(
2033 out,
2034 "{} {} {} {}{}",
2035 styled_bytes(pick(size, row.bytes, row.allocated), TEXT_SIZE_WIDTH, color, false),
2036 label_cell(&escaped_human(&row.extension), width, STYLE_CATEGORY, color),
2037 human_count(row.files),
2038 plural(row.files, "file", "files"),
2039 ignored_suffix(row.ignored, size, selected, color),
2040 );
2041 }
2042}
2043
2044fn bounded_rows(section: &Section) -> Option<(usize, usize)> {
2047 let (shown, total) = match section {
2048 Section::Code(overview) => (overview.languages.len(), overview.total_languages),
2049 Section::Files { rows, total, .. } => (rows.len(), *total),
2050 Section::Extensions { rows, total, .. } => (rows.len(), *total),
2051 Section::Metrics { summary, .. } => (summary.rows.len(), summary.total_rows),
2052 Section::Tree { .. } | Section::Summary(_) => return None,
2054 };
2055 (shown < total).then_some((shown, total))
2056}
2057
2058fn bound_note(section: &Section) -> String {
2061 let Some((shown, total)) = bounded_rows(section) else {
2062 return String::new();
2063 };
2064 format!(" ({} of {})", human_count(shown as u64), human_count(total as u64))
2065}
2066
2067fn render_text_ranked_files(
2072 out: &mut String,
2073 rows: &[FileRow],
2074 color: bool,
2075 size: Option<SizeMetric>,
2076 measure: impl Fn(&FileRow) -> String,
2077) {
2078 let width = rows.iter().map(|row| display_width(&measure(row))).max().unwrap_or_default();
2079 for row in rows {
2080 let value = size.map_or_else(
2081 || format!("{:>width$}", measure(row)),
2082 |size| styled_bytes(pick(size, row.bytes, row.allocated), width, color, false),
2083 );
2084 let _ = writeln!(
2085 out,
2086 "{} {}",
2087 value,
2088 human_name(&row.path.to_string_lossy(), row.kind, row.ignored, color)
2089 );
2090 }
2091}
2092
2093fn render_text_metric_files(out: &mut String, rows: &[FileRow], color: bool) {
2094 let width = rows
2095 .iter()
2096 .map(|row| row.sort_value.map_or(1, |value| human_count(value).len()))
2097 .max()
2098 .unwrap_or_default();
2099 for row in rows {
2100 let value = row.sort_value.map_or_else(|| "—".to_string(), human_count);
2101 let classification =
2102 row.classification.as_ref().map_or_else(String::new, |classification| {
2103 let mut parts =
2104 vec![classification.file_type.as_str(), classification.source.as_str()];
2105 if classification.flags.generated {
2106 parts.push("generated");
2107 }
2108 if classification.flags.vendored {
2109 parts.push("vendored");
2110 }
2111 if classification.flags.documentation {
2112 parts.push("documentation");
2113 }
2114 format!(" {}", detail(&format!("({})", parts.join(", ")), color))
2115 });
2116 let _ = writeln!(
2117 out,
2118 "{:>width$} {}{}",
2119 value,
2120 human_name(&row.path.to_string_lossy(), row.kind, row.ignored, color),
2121 classification
2122 );
2123 }
2124}
2125
2126fn render_text_summary(
2127 out: &mut String,
2128 row: &SummaryRow,
2129 size: SizeMetric,
2130 selected: IgnoredEntries,
2131 color: bool,
2132) {
2133 let _ = writeln!(
2134 out,
2135 "{} {} {}, {} {}{}",
2136 styled_bytes(pick(size, row.bytes, row.allocated), 10, color, false),
2137 human_count(row.files),
2138 plural(row.files, "file", "files"),
2139 human_count(row.dirs),
2140 plural(row.dirs, "directory", "directories"),
2141 ignored_suffix(row.ignored, size, selected, color),
2142 );
2143}
2144
2145#[cfg(test)]
2150fn yaml_scalar(value: &str) -> String {
2151 let mut out = String::new();
2152 crate::emit::write_yaml_scalar(&mut out, value);
2153 out
2154}
2155
2156#[cfg(test)]
2158fn quote(text: &str) -> String {
2159 let mut out = String::with_capacity(text.len() + 2);
2160 crate::emit::write_json_string(&mut out, text);
2161 out
2162}
2163
2164fn generator() -> String {
2166 format!("fdu {}", env!("CARGO_PKG_VERSION"))
2167}
2168
2169fn view_header(view: ViewSpec) -> &'static str {
2177 match view {
2178 ViewSpec::List => "LIST",
2179 ViewSpec::Tree => "TREE",
2180 ViewSpec::Types => "TYPES",
2181 ViewSpec::Extensions => "EXTENSIONS",
2182 ViewSpec::Families => "FAMILIES",
2183 ViewSpec::Languages => "LANGUAGES",
2184 ViewSpec::Code => "CODE",
2185 ViewSpec::Documents => "DOCUMENTS",
2186 ViewSpec::Files => "FILES",
2187 ViewSpec::Largest => "LARGEST",
2188 ViewSpec::Recent => "RECENT",
2189 ViewSpec::Summary => "SUMMARY",
2190 }
2191}
2192
2193fn metric_group_label(group: MetricGroup) -> &'static str {
2194 match group {
2195 MetricGroup::Type => "type",
2196 MetricGroup::Family => "family",
2197 }
2198}
2199
2200fn coverage_label(reason: CoverageReason) -> &'static str {
2201 match reason {
2202 CoverageReason::Analyzed => "analyzed",
2203 CoverageReason::Binary => "binary",
2204 CoverageReason::InvalidUtf8 => "invalid_utf8",
2205 CoverageReason::UnsupportedEncoding => "unsupported_encoding",
2206 CoverageReason::Unsupported => "unsupported",
2207 CoverageReason::IoError => "io_error",
2208 CoverageReason::ChangedDuringRead => "changed_during_read",
2209 }
2210}
2211
2212fn analysis_set_labels(profile: crate::content::AnalysisSet) -> Vec<&'static str> {
2217 profile.labels()
2218}
2219
2220fn source_label(source: ReportSource) -> &'static str {
2222 match source {
2223 ReportSource::ColdScan => "cold_scan",
2224 ReportSource::WarmRevalidate => "warm_revalidate",
2225 ReportSource::CacheOnly => "cache_only",
2226 }
2227}
2228
2229fn tier_source_label(source: Source) -> &'static str {
2230 match source {
2231 Source::Scanned => "scanned",
2232 Source::Revalidated => "revalidated",
2233 Source::JournalScoped => "journal_scoped",
2234 Source::Cached => "cached",
2235 }
2236}
2237
2238fn structural_coverage_label(reason: crate::engine_contract::CoverageReason) -> &'static str {
2239 match reason {
2240 crate::engine_contract::CoverageReason::Building => "building",
2241 crate::engine_contract::CoverageReason::Budget => "budget",
2242 crate::engine_contract::CoverageReason::Cancelled => "cancelled",
2243 crate::engine_contract::CoverageReason::Inaccessible => "inaccessible",
2244 crate::engine_contract::CoverageReason::Failed => "failed",
2245 }
2246}
2247
2248fn issue_kind_label(kind: IssueKind) -> &'static str {
2249 match kind {
2250 IssueKind::Permission => "permission",
2251 IssueKind::Disappeared => "disappeared",
2252 IssueKind::InvalidMetadata => "invalid_metadata",
2253 IssueKind::ResourceBudget => "resource_budget",
2254 IssueKind::ObservationGap => "observation_gap",
2255 IssueKind::ProviderFailure => "provider_failure",
2256 }
2257}
2258
2259fn freshness_label(freshness: Freshness) -> &'static str {
2261 match freshness {
2262 Freshness::Fresh => "fresh",
2263 Freshness::Reconciling => "reconciling",
2264 Freshness::Stale => "stale",
2265 Freshness::Partial => "partial",
2266 }
2267}
2268
2269fn kind_label(kind: EntryKind) -> &'static str {
2271 match kind {
2272 EntryKind::File => "file",
2273 EntryKind::Dir => "dir",
2274 EntryKind::Symlink => "symlink",
2275 EntryKind::Other => "other",
2276 }
2277}
2278
2279fn pick(size: SizeMetric, apparent: u64, allocated: u64) -> u64 {
2281 match size {
2282 SizeMetric::Apparent => apparent,
2283 SizeMetric::Allocated => allocated,
2284 }
2285}
2286
2287fn plural<'a>(count: u64, singular: &'a str, plural: &'a str) -> &'a str {
2289 if count == 1 { singular } else { plural }
2290}
2291
2292fn ratio(part: u64, whole: u64) -> f64 {
2294 if whole == 0 {
2295 return 0.0;
2296 }
2297 #[allow(clippy::cast_precision_loss)]
2298 let share = part as f64 / whole as f64;
2299 share.clamp(0.0, 1.0)
2300}
2301
2302fn bar_cells(part: u64, whole: u64, width: usize) -> usize {
2306 if whole == 0 {
2307 return 0;
2308 }
2309 let numerator = u128::from(part.min(whole)) * width as u128;
2310 usize::try_from((numerator + u128::from(whole) / 2) / u128::from(whole))
2311 .expect("a bounded share fits the supplied width")
2312}
2313
2314fn usage_bar(bytes: u64, total: u64, ignored: Option<u64>, color: bool, width: usize) -> String {
2320 if width == 0 {
2321 return String::new();
2322 }
2323 let filled = bar_cells(bytes, total, width);
2324 if !color {
2325 return format!("{}{}", "█".repeat(filled), "░".repeat(width - filled));
2326 }
2327 if ignored.is_none() {
2328 return format!(
2329 "{}{}",
2330 paint(&"▒".repeat(filled), STYLE_BAR, true),
2331 paint(&"░".repeat(width - filled), STYLE_BAR.dimmed(), true)
2332 );
2333 }
2334 let ignored = bar_cells(ignored.unwrap_or(0), bytes, filled);
2335 format!(
2336 "{}{}{}",
2337 paint(&"█".repeat(filled - ignored), STYLE_BAR, true),
2338 paint(&"▓".repeat(ignored), STYLE_BAR, true),
2339 paint(&"░".repeat(width - filled), STYLE_BAR.dimmed(), true)
2340 )
2341}
2342
2343pub fn human_count(value: u64) -> String {
2349 human_count_u128(u128::from(value))
2350}
2351
2352pub fn human_count_u128(value: u128) -> String {
2357 let digits = value.to_string();
2358 let mut grouped = String::with_capacity(digits.len() + digits.len() / 3);
2359 for (index, byte) in digits.bytes().enumerate() {
2360 if index > 0 && (digits.len() - index) % 3 == 0 {
2361 grouped.push(',');
2362 }
2363 grouped.push(char::from(byte));
2364 }
2365 grouped
2366}
2367
2368pub fn human_bytes(bytes: u64) -> String {
2374 const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
2375 let mut whole = bytes;
2379 let mut remainder = 0u64;
2380 let mut unit = 0;
2381 while whole >= 1024 && unit + 1 < UNITS.len() {
2382 remainder = whole % 1024;
2383 whole /= 1024;
2384 unit += 1;
2385 }
2386 if unit == 0 {
2387 format!("{} B", human_count(bytes))
2388 } else if whole < 10 {
2389 let tenths = (remainder * 10) / 1024;
2390 format!("{}.{tenths} {}", human_count(whole), UNITS[unit])
2391 } else {
2392 format!("{} {}", human_count(whole), UNITS[unit])
2393 }
2394}
2395
2396pub fn is_lossy(path: &Path) -> bool {
2401 path.to_str().is_none()
2402}
2403
2404pub const STREAM_SCHEMA: &str = "fdu.stream/2";
2410
2411pub fn watch_rule(at: std::time::SystemTime) -> String {
2422 format!("──── {} ────", format_rfc3339(at))
2423}
2424
2425pub fn watch_rule_nanos(at_nanos: i64) -> String {
2431 format!("──── {} ────", format_rfc3339_nanos(at_nanos))
2432}
2433
2434#[cfg(feature = "watch")]
2436pub fn render_change(change: &crate::Change, format: Format) -> String {
2437 let kind = match change.kind {
2438 crate::ChangeKind::Upsert => "upsert",
2439 crate::ChangeKind::Remove => "remove",
2440 crate::ChangeKind::Invalidate => "invalidate",
2441 };
2442
2443 if !format.is_machine() {
2444 return format!("{}\t{kind}", change.path.display());
2447 }
2448
2449 match format {
2450 Format::Json | Format::Jsonl => render_change_machine(change, kind, JsonSink::line()),
2451 Format::Yaml => {
2452 format!(
2453 "{}{}",
2454 document_start(format),
2455 render_change_machine(change, kind, YamlSink::new())
2456 )
2457 }
2458 Format::Text | Format::Tree | Format::Paths | Format::Long => {
2459 unreachable!("text returned above")
2460 }
2461 }
2462}
2463
2464#[cfg(feature = "watch")]
2465fn render_change_machine(
2466 change: &crate::Change,
2467 kind: &str,
2468 mut sink: impl Sink<Output = String>,
2469) -> String {
2470 sink.event(Event::BeginMap(Shape::Block));
2471 emit_str_field(&mut sink, "schema", STREAM_SCHEMA);
2472 emit_str_field(&mut sink, "record", "change");
2473 emit_str_field(&mut sink, "op", kind);
2474 emit_path_fields(&mut sink, &change.path);
2475 emit_u64_field(&mut sink, "clock", change.clock);
2476 emit_field(&mut sink, Field::when_set("kind"), change.entry_kind.is_some(), |sink| {
2477 emit_scalar(sink, Scalar::Str(kind_label(change.entry_kind.expect("presence checked"))));
2478 });
2479 emit_field(&mut sink, Field::when_set("bytes"), change.bytes.is_some(), |sink| {
2480 emit_scalar(sink, Scalar::U64(change.bytes.expect("presence checked")));
2481 });
2482 emit_field(&mut sink, Field::when_set("allocated"), change.allocated.is_some(), |sink| {
2483 emit_scalar(sink, Scalar::U64(change.allocated.expect("presence checked")));
2484 });
2485 emit_field(&mut sink, Field::when_set("mtime_ns"), change.mtime_ns.is_some(), |sink| {
2486 emit_scalar(sink, Scalar::I64(change.mtime_ns.expect("presence checked")));
2487 });
2488 emit_field(&mut sink, Field::when_set("ignored"), change.ignored.is_some(), |sink| {
2489 emit_scalar(sink, Scalar::Bool(change.ignored.expect("presence checked")));
2490 });
2491 sink.event(Event::EndMap);
2492 sink.finish()
2493}
2494
2495fn raw_os_identity(value: &std::ffi::OsStr) -> Option<(&'static str, String)> {
2501 if value.to_str().is_some() {
2502 return None;
2503 }
2504
2505 #[cfg(unix)]
2506 {
2507 use std::os::unix::ffi::OsStrExt;
2508
2509 Some(("unix-bytes", hex_bytes(value.as_bytes().iter().copied())))
2510 }
2511
2512 #[cfg(windows)]
2513 {
2514 use std::os::windows::ffi::OsStrExt;
2515
2516 Some(("windows-wtf16le", hex_bytes(value.encode_wide().flat_map(u16::to_le_bytes))))
2517 }
2518
2519 #[cfg(not(any(unix, windows)))]
2520 {
2521 None
2522 }
2523}
2524
2525#[cfg(any(unix, windows))]
2527fn hex_bytes(bytes: impl IntoIterator<Item = u8>) -> String {
2528 let mut out = String::new();
2529 for byte in bytes {
2530 let _ = write!(out, "{byte:02x}");
2531 }
2532 out
2533}
2534
2535pub fn render_cache_status(
2549 statuses: &[crate::CacheStatus],
2550 scope: crate::CacheScope,
2551 format: Format,
2552) -> String {
2553 render_cache_status_with_options(statuses, scope, format, RenderOptions::default())
2554}
2555
2556pub fn render_cache_status_with_options(
2559 statuses: &[crate::CacheStatus],
2560 scope: crate::CacheScope,
2561 format: Format,
2562 options: RenderOptions,
2563) -> String {
2564 match format {
2565 Format::Jsonl => {
2566 let mut sink = JsonSink::line();
2567 sink.event(Event::BeginMap(Shape::Inline));
2568 emit_str_field(&mut sink, "schema", CACHE_SCHEMA);
2569 sink.event(Event::EndMap);
2570 let mut out = sink.finish();
2571 for status in statuses {
2572 out.push('\n');
2573 let row = cache_row(status);
2574 let mut sink = JsonSink::line();
2575 emit_cache_field(&mut sink, &row);
2576 out.push_str(&sink.finish());
2577 }
2578 out
2579 }
2580 Format::Yaml => render_cache_machine(statuses, YamlSink::new()),
2581 Format::Text | Format::Tree | Format::Paths | Format::Long => {
2586 render_cache_status_text(statuses, scope, options.color)
2587 }
2588 Format::Json => render_cache_machine(statuses, JsonSink::pretty()),
2589 }
2590}
2591
2592fn render_cache_machine(
2593 statuses: &[crate::CacheStatus],
2594 mut sink: impl Sink<Output = String>,
2595) -> String {
2596 sink.event(Event::BeginMap(Shape::Block));
2597 emit_str_field(&mut sink, "schema", CACHE_SCHEMA);
2598 emit_field(&mut sink, Field::always("caches"), true, |sink| {
2599 sink.event(Event::BeginSeq(Shape::Block));
2600 for status in statuses {
2601 let row = cache_row(status);
2602 emit_cache_field(sink, &row);
2603 }
2604 sink.event(Event::EndSeq);
2605 });
2606 sink.event(Event::EndMap);
2607 sink.finish()
2608}
2609
2610fn emit_cache_field(sink: &mut impl Sink, field: &CacheField) {
2611 match field {
2612 CacheField::Null => emit_scalar(sink, Scalar::Null),
2613 CacheField::Bool(value) => emit_scalar(sink, Scalar::Bool(*value)),
2614 CacheField::Count(value) => emit_scalar(sink, Scalar::U64(*value)),
2615 CacheField::Text(value) => emit_scalar(sink, Scalar::Str(value)),
2616 CacheField::List(values) => {
2617 sink.event(Event::BeginSeq(Shape::Block));
2618 for value in values {
2619 emit_cache_field(sink, value);
2620 }
2621 sink.event(Event::EndSeq);
2622 }
2623 CacheField::Map(fields) => {
2624 sink.event(Event::BeginMap(Shape::Block));
2625 for (name, value) in fields {
2626 sink.event(Event::Key(name));
2627 emit_cache_field(sink, value);
2628 }
2629 sink.event(Event::EndMap);
2630 }
2631 }
2632}
2633
2634enum CacheField {
2639 Null,
2640 Bool(bool),
2641 Count(u64),
2642 Text(String),
2643 List(Vec<CacheField>),
2644 Map(Vec<(&'static str, CacheField)>),
2645}
2646
2647impl CacheField {
2648 fn count(value: Option<u64>) -> Self {
2649 value.map_or(Self::Null, Self::Count)
2650 }
2651}
2652
2653fn cache_row(status: &crate::CacheStatus) -> CacheField {
2656 use crate::CacheState;
2657
2658 let mut fields = vec![("path", CacheField::Text(status.path.to_string_lossy().into_owned()))];
2659 if let Some((encoding, hex)) = raw_os_identity(status.path.as_os_str()) {
2660 fields.push((
2661 "path_raw",
2662 CacheField::Map(vec![
2663 ("encoding", CacheField::Text(encoding.to_string())),
2664 ("hex", CacheField::Text(hex)),
2665 ]),
2666 ));
2667 }
2668 fields.extend([
2669 ("bytes", CacheField::Count(status.bytes)),
2670 ("state", CacheField::Text(status.state.label().to_string())),
2671 ]);
2672 match &status.state {
2673 CacheState::Current(info) => {
2674 fields.push(("root", CacheField::Text(info.root.to_string_lossy().into_owned())));
2675 if let Some((encoding, hex)) = raw_os_identity(info.root.as_os_str()) {
2676 fields.push((
2677 "root_raw",
2678 CacheField::Map(vec![
2679 ("encoding", CacheField::Text(encoding.to_string())),
2680 ("hex", CacheField::Text(hex)),
2681 ]),
2682 ));
2683 }
2684 fields.push(("entries", CacheField::Count(info.entries)));
2685 fields.push(("identity", snapshot_identity_field(info.identity)));
2686 }
2687 CacheState::Stale(reason) => fields.extend(stale_fields(*reason)),
2688 CacheState::Leftover(kind) => {
2689 fields.push(("leftover_kind", CacheField::Text(kind.label().to_string())));
2690 }
2691 CacheState::Unrecognized | CacheState::Absent => {}
2692 }
2693 fields.push(("content", status.content.as_ref().map_or(CacheField::Null, content_field)));
2696 CacheField::Map(fields)
2697}
2698
2699fn stale_fields(reason: crate::StaleReason) -> [(&'static str, CacheField); 2] {
2701 [
2702 ("stale_reason", CacheField::Text(reason.label().to_string())),
2703 ("format_version", CacheField::count(reason.format_version().map(u64::from))),
2704 ]
2705}
2706
2707fn content_field(content: &crate::ContentStatus) -> CacheField {
2710 use crate::ContentState;
2711
2712 let mut fields = vec![
2713 ("bytes", CacheField::Count(content.bytes)),
2714 ("state", CacheField::Text(content.state.label().to_string())),
2715 ];
2716 match &content.state {
2717 ContentState::Current(info) => {
2718 fields.push(("records", CacheField::Count(info.records)));
2719 fields.push(("identity", content_identity_field(&info.identity)));
2720 }
2721 ContentState::Stale(reason) => fields.extend(stale_fields(*reason)),
2722 }
2723 CacheField::Map(fields)
2724}
2725
2726fn snapshot_identity_field(identity: crate::SnapshotIdentity) -> CacheField {
2729 let ignore_rules = match identity.controls {
2730 crate::ControlTierIdentity::NotObserved => CacheField::Null,
2731 crate::ControlTierIdentity::Observed { limits } => {
2732 let limit = |limit: Option<usize>| {
2733 CacheField::count(limit.map(|limit| u64::try_from(limit).unwrap_or(u64::MAX)))
2734 };
2735 CacheField::Map(vec![(
2736 "limits",
2737 CacheField::Map(vec![
2738 ("budget", limit(limits.budget)),
2739 ("line_limit", limit(limits.line_limit)),
2740 ]),
2741 )])
2742 }
2743 };
2744 CacheField::Map(vec![
2745 ("entries", entry_identity_field(identity.entries)),
2746 ("ignore_rules", ignore_rules),
2747 ])
2748}
2749
2750fn entry_identity_field(identity: crate::EntryTierIdentity) -> CacheField {
2753 let scope = identity.scope;
2754 let depth = scope.max_depth.map(|depth| u64::try_from(depth).unwrap_or(u64::MAX));
2755 CacheField::Map(vec![
2756 ("engine", CacheField::Count(identity.engine)),
2757 ("max_depth", CacheField::count(depth)),
2758 ("follow_symlinks", CacheField::Bool(scope.follow_symlinks)),
2759 ("one_filesystem", CacheField::Bool(scope.one_filesystem)),
2760 ("hidden_fingerprint", CacheField::Count(scope.hidden_fingerprint)),
2761 ("exclude_special", CacheField::Bool(scope.exclude_special)),
2762 ("population", CacheField::Text(scope.population.label().to_string())),
2763 ("control_fingerprint", CacheField::Count(scope.control_fingerprint)),
2764 ("type_rules_fingerprint", CacheField::Count(identity.type_rules_fingerprint)),
2765 ("reducers_fingerprint", CacheField::Count(identity.reducers_fingerprint)),
2766 ])
2767}
2768
2769fn content_identity_field(identity: &crate::ContentTierIdentity) -> CacheField {
2773 let analyze = analysis_set_labels(identity.analysis)
2774 .into_iter()
2775 .map(|label| CacheField::Text(label.to_string()))
2776 .collect();
2777 let analyzers = identity
2778 .provenance
2779 .analyzers
2780 .iter()
2781 .map(|(id, version)| {
2782 CacheField::Map(vec![
2783 ("id", CacheField::Text(id.0.to_string())),
2784 ("version", CacheField::Count(u64::from(version.0))),
2785 ])
2786 })
2787 .collect();
2788 CacheField::Map(vec![
2789 ("entries", entry_identity_field(identity.entries)),
2790 ("analyze", CacheField::List(analyze)),
2791 ("options_fingerprint", CacheField::Count(identity.provenance.options_fingerprint.0)),
2792 ("analyzers", CacheField::List(analyzers)),
2793 ])
2794}
2795
2796fn render_cache_status_text(
2799 statuses: &[crate::CacheStatus],
2800 scope: crate::CacheScope,
2801 color: bool,
2802) -> String {
2803 use crate::{CacheScope, CacheState, LeftoverKind, StaleReason};
2804
2805 let mut lines = Vec::new();
2806 let mut current = 0_usize;
2807 let (mut stale, mut stale_bytes) = (0_usize, 0_u64);
2808 let (mut leftover, mut leftover_bytes, mut staging) = (0_usize, 0_u64, 0_usize);
2809 let (mut unrecognized, mut unrecognized_bytes) = (0_usize, 0_u64);
2810 for status in statuses {
2811 let content_bytes = status.content_bytes().unwrap_or(0);
2812 match &status.state {
2813 CacheState::Current(info) => {
2814 current += 1;
2815 let stale_content = status
2818 .content
2819 .as_ref()
2820 .is_some_and(|content| matches!(content.state, crate::ContentState::Stale(_)));
2821 lines.push(format!(
2822 "{} {} entries, {} metadata, {} {}content {}",
2823 status.path.display(),
2824 human_count(info.entries),
2825 styled_bytes(status.bytes, 0, color, false),
2826 styled_bytes(content_bytes, 0, color, false),
2827 if stale_content { "stale " } else { "" },
2828 info.root.display()
2829 ));
2830 }
2831 CacheState::Stale(reason) => {
2832 stale += 1;
2833 stale_bytes =
2834 stale_bytes.saturating_add(status.bytes).saturating_add(content_bytes);
2835 let why = match reason {
2836 StaleReason::OlderFormat { version } => {
2837 format!("older snapshot format {version}")
2838 }
2839 StaleReason::NewerFormat { version } => {
2840 format!("newer snapshot format {version}")
2841 }
2842 StaleReason::OtherEngine => "written by another fdu version".to_string(),
2843 StaleReason::Unreadable => "unreadable by this build".to_string(),
2844 };
2845 lines.push(format!(
2846 "{} stale {}, {} metadata, {} content",
2847 status.path.display(),
2848 detail(&format!("({why})"), color),
2849 styled_bytes(status.bytes, 0, color, false),
2850 styled_bytes(content_bytes, 0, color, false)
2851 ));
2852 }
2853 CacheState::Leftover(kind) => {
2854 leftover += 1;
2855 leftover_bytes = leftover_bytes.saturating_add(status.bytes);
2856 let what = match kind {
2857 LeftoverKind::StagingTemporary => {
2858 staging += 1;
2859 "staging temporary"
2860 }
2861 LeftoverKind::OrphanedContent => "orphaned content sidecar",
2862 };
2863 lines.push(format!(
2864 "{} leftover {}, {}",
2865 status.path.display(),
2866 detail(&format!("({what})"), color),
2867 styled_bytes(status.bytes, 0, color, false)
2868 ));
2869 }
2870 CacheState::Unrecognized => {
2871 unrecognized += 1;
2872 unrecognized_bytes = unrecognized_bytes.saturating_add(status.bytes);
2873 lines.push(format!(
2874 "{} unrecognized, {}",
2875 status.path.display(),
2876 styled_bytes(status.bytes, 0, color, false)
2877 ));
2878 }
2879 CacheState::Absent => {}
2883 }
2884 }
2885 if lines.is_empty() {
2886 return "No cached snapshots.".to_string();
2887 }
2888
2889 if stale > 0 {
2890 let (subject, object) = if stale == 1 {
2891 ("1 stale snapshot".to_string(), "it")
2892 } else {
2893 (format!("{} stale snapshots", human_count_u128(stale as u128)), "them")
2894 };
2895 let remedy = match scope {
2896 CacheScope::Root => format!("fdu --cache-clear PATH removes {object}"),
2897 CacheScope::All if current == 0 => format!("fdu --cache-clear=all removes {object}"),
2898 CacheScope::All => {
2899 format!("fdu --cache-clear=all removes {object}, along with every current snapshot")
2900 }
2901 };
2902 lines.push(format!(
2903 "{subject} {} cannot be served by this build; {remedy}.",
2904 cache_size_detail(stale_bytes, color)
2905 ));
2906 }
2907 if leftover > 0 {
2908 let (subject, predicate, object) = if leftover == 1 {
2912 ("1 leftover file".to_string(), "is", "it")
2913 } else {
2914 (format!("{} leftover files", human_count_u128(leftover as u128)), "are", "them")
2915 };
2916 let caveat = if staging > 0 {
2920 ", though a staging file waits until it is too old to be a running writer's"
2921 } else {
2922 ""
2923 };
2924 lines.push(format!(
2925 "{subject} {} {predicate} fdu's own, left by an interrupted \
2926 write; fdu --cache-clear=all reclaims {object}{caveat}.",
2927 cache_size_detail(leftover_bytes, color)
2928 ));
2929 }
2930 if unrecognized > 0 {
2931 let (subject, predicate, object) = if unrecognized == 1 {
2932 ("1 unrecognized file".to_string(), "is not an fdu snapshot", "it")
2933 } else {
2934 (
2935 format!("{} unrecognized files", human_count_u128(unrecognized as u128)),
2936 "are not fdu snapshots",
2937 "them",
2938 )
2939 };
2940 lines.push(format!(
2941 "{subject} {} {predicate}, so fdu leaves {object} in place.",
2942 cache_size_detail(unrecognized_bytes, color)
2943 ));
2944 }
2945 lines.join("\n")
2946}
2947
2948fn cache_size_detail(bytes: u64, color: bool) -> String {
2949 format!("{}{}{}", detail("(", color), styled_bytes(bytes, 0, color, true), detail(")", color))
2950}
2951
2952#[cfg(test)]
2953mod tests {
2954 fn render(report: &Report, format: Format, color: bool) -> String {
2955 super::render(report, format, color).expect("compatible report format")
2956 }
2957
2958 use super::*;
2959 use crate::Index;
2960 use crate::engine_contract::{Attrs, Observation, Op, ScanScope};
2961 use crate::query::{Bound, Query, Request, Selection, ShareThreshold};
2962 use std::ffi::OsStr;
2963 use std::path::PathBuf;
2964 use std::process::{Command, Stdio};
2965 use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
2966
2967 struct Provenance {
2968 scan_started_at: Option<SystemTime>,
2969 generated_at: SystemTime,
2970 source: ReportSource,
2971 complete: bool,
2972 errors: Vec<String>,
2973 }
2974
2975 fn report(index: &Index, request: &Request, provenance: &Provenance) -> crate::Result<Report> {
2976 let mut report = crate::query::report(index, request, provenance.generated_at)?;
2977 report.provenance.scan_started_at = provenance.scan_started_at;
2978 report.provenance.source = provenance.source;
2979 report.status.complete = provenance.complete;
2980 report.status.errors = provenance
2981 .errors
2982 .iter()
2983 .cloned()
2984 .map(|message| crate::Issue::provider_failure(None, message))
2985 .collect();
2986 Ok(report)
2987 }
2988
2989 fn attrs(size: u64, mtime_ns: i64) -> Attrs {
2990 Attrs {
2991 size,
2992 allocated: size.div_ceil(512) * 512,
2993 mtime_ns,
2994 ctime_ns: mtime_ns,
2995 inode: 7,
2996 dev: 1,
2997 }
2998 }
2999
3000 fn cache_file(name: &str, bytes: u64, state: crate::CacheState) -> crate::CacheStatus {
3001 let content =
3003 matches!(state, crate::CacheState::Stale(_)).then_some(crate::ContentStatus {
3004 bytes: 5,
3005 state: crate::ContentState::Stale(crate::StaleReason::OlderFormat { version: 4 }),
3006 });
3007 crate::CacheStatus { path: PathBuf::from(name), bytes, content, state }
3008 }
3009
3010 fn small_snapshot_identity() -> crate::SnapshotIdentity {
3012 crate::SnapshotIdentity {
3013 entries: crate::EntryTierIdentity {
3014 engine: 1,
3015 scope: crate::EntryScope {
3016 max_depth: None,
3017 follow_symlinks: false,
3018 one_filesystem: true,
3019 hidden_fingerprint: 2,
3020 exclude_special: false,
3021 population: IgnoredEntries::Include,
3022 control_fingerprint: 0,
3023 },
3024 type_rules_fingerprint: 3,
3025 reducers_fingerprint: 4,
3026 },
3027 controls: crate::ControlTierIdentity::Observed {
3028 limits: crate::control::ControlLimits { budget: Some(10), line_limit: None },
3029 },
3030 }
3031 }
3032
3033 #[test]
3040 fn cache_status_shows_stale_and_unrecognized_files_with_their_remedy() {
3041 use crate::{CacheScope, CacheState, LeftoverKind, StaleReason};
3042
3043 let stale = [
3044 cache_file("a.fdu", 10, CacheState::Stale(StaleReason::OlderFormat { version: 2 })),
3045 cache_file("b.fdu", 20, CacheState::Stale(StaleReason::NewerFormat { version: 99 })),
3046 cache_file("c.fdu", 30, CacheState::Stale(StaleReason::OtherEngine)),
3047 cache_file("d.fdu", 40, CacheState::Stale(StaleReason::Unreadable)),
3048 cache_file("notes.txt", 14, CacheState::Unrecognized),
3049 ];
3050 assert_eq!(
3051 render_cache_status(&stale, CacheScope::All, Format::Text),
3052 "a.fdu stale (older snapshot format 2), 10 B metadata, 5 B content\n\
3053 b.fdu stale (newer snapshot format 99), 20 B metadata, 5 B content\n\
3054 c.fdu stale (written by another fdu version), 30 B metadata, 5 B content\n\
3055 d.fdu stale (unreadable by this build), 40 B metadata, 5 B content\n\
3056 notes.txt unrecognized, 14 B\n\
3057 4 stale snapshots (120 B) cannot be served by this build; \
3058 fdu --cache-clear=all removes them.\n\
3059 1 unrecognized file (14 B) is not an fdu snapshot, so fdu leaves it in place."
3060 );
3061 let colored = render_cache_status_with_options(
3062 &stale,
3063 CacheScope::All,
3064 Format::Text,
3065 RenderOptions { color: true, ..RenderOptions::default() },
3066 );
3067 assert!(colored.contains(&detail("(older snapshot format 2)", true)), "{colored:?}");
3068 assert!(
3069 colored.contains(&format!("4 stale snapshots {} cannot", cache_size_detail(120, true))),
3070 "{colored:?}"
3071 );
3072 assert_eq!(
3073 strip_ansi(&colored),
3074 render_cache_status(&stale, CacheScope::All, Format::Text)
3075 );
3076
3077 let leftovers = [
3079 cache_file(
3080 ".g.fdu.tmp.1.2.3",
3081 60,
3082 CacheState::Leftover(LeftoverKind::StagingTemporary),
3083 ),
3084 cache_file("h.analysis.bin", 70, CacheState::Leftover(LeftoverKind::OrphanedContent)),
3085 ];
3086 assert_eq!(
3087 render_cache_status(&leftovers, CacheScope::All, Format::Text),
3088 ".g.fdu.tmp.1.2.3 leftover (staging temporary), 60 B\n\
3089 h.analysis.bin leftover (orphaned content sidecar), 70 B\n\
3090 2 leftover files (130 B) are fdu's own, left by an interrupted write; \
3091 fdu --cache-clear=all reclaims them, though a staging file waits until it is \
3092 too old to be a running writer's."
3093 );
3094 assert!(render_cache_status(&leftovers[..1], CacheScope::Root, Format::Text).ends_with(
3095 "1 leftover file (60 B) is fdu's own, left by an interrupted write; \
3096 fdu --cache-clear=all reclaims it, though a staging file waits until it \
3097 is too old to be a running writer's."
3098 ));
3099 assert!(render_cache_status(&leftovers[1..], CacheScope::All, Format::Text).ends_with(
3102 "1 leftover file (70 B) is fdu's own, left by an interrupted write; \
3103 fdu --cache-clear=all reclaims it."
3104 ));
3105
3106 let root = [cache_file("a.fdu", 10, CacheState::Stale(StaleReason::OtherEngine))];
3107 assert!(render_cache_status(&root, CacheScope::Root, Format::Text).ends_with(
3108 "1 stale snapshot (15 B) cannot be served by this build; \
3109 fdu --cache-clear PATH removes it."
3110 ));
3111
3112 let current = cache_file(
3113 "e.fdu",
3114 50,
3115 CacheState::Current(crate::SnapshotInfo {
3116 root: PathBuf::from("/tree"),
3117 identity: small_snapshot_identity(),
3118 entries: 3,
3119 }),
3120 );
3121 let mixed = [stale[0].clone(), current, stale[4].clone(), stale[4].clone()];
3122 assert!(render_cache_status(&mixed, CacheScope::All, Format::Text).ends_with(
3123 "e.fdu 3 entries, 50 B metadata, 0 B content /tree\n\
3124 notes.txt unrecognized, 14 B\n\
3125 notes.txt unrecognized, 14 B\n\
3126 1 stale snapshot (15 B) cannot be served by this build; \
3127 fdu --cache-clear=all removes it, along with every current snapshot.\n\
3128 2 unrecognized files (28 B) are not fdu snapshots, so fdu leaves them in place."
3129 ));
3130
3131 let absent = [cache_file("f.fdu", 0, CacheState::Absent)];
3132 assert_eq!(
3133 render_cache_status(&absent, CacheScope::Root, Format::Text),
3134 "No cached snapshots."
3135 );
3136 assert_eq!(
3139 render_cache_status(
3140 &[stale[0].clone(), absent[0].clone(), stale[4].clone(), leftovers[0].clone()],
3141 CacheScope::All,
3142 Format::Jsonl
3143 ),
3144 "{\"schema\": \"fdu.cache/3\"}\n\
3145 {\"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\
3146 {\"path\": \"f.fdu\", \"bytes\": 0, \"state\": \"absent\", \"content\": null}\n\
3147 {\"path\": \"notes.txt\", \"bytes\": 14, \"state\": \"unrecognized\", \"content\": null}\n\
3148 {\"path\": \".g.fdu.tmp.1.2.3\", \"bytes\": 60, \"state\": \"leftover\", \"leftover_kind\": \"staging_temporary\", \"content\": null}"
3149 );
3150 assert_eq!(
3151 render_cache_status(&[], CacheScope::All, Format::Jsonl),
3152 "{\"schema\": \"fdu.cache/3\"}"
3153 );
3154 assert_eq!(
3155 render_cache_status(&[], CacheScope::All, Format::Json),
3156 "{\n \"schema\": \"fdu.cache/3\",\n \"caches\": []\n}\n"
3157 );
3158 assert_eq!(
3161 render_cache_status(&[], CacheScope::All, Format::Yaml),
3162 "schema: fdu.cache/3\ncaches: []\n"
3163 );
3164 assert!(
3165 render_cache_status(&stale[2..3], CacheScope::All, Format::Json)
3166 .starts_with("{\n \"schema\": \"fdu.cache/3\",\n \"caches\": [\n {")
3167 );
3168 assert!(
3169 render_cache_status(&stale[2..3], CacheScope::All, Format::Yaml)
3170 .starts_with("schema: fdu.cache/3\ncaches:\n -\n path: c.fdu")
3171 );
3172 assert!(
3173 render_cache_status(&stale[2..3], CacheScope::All, Format::Yaml).ends_with(
3174 "state: stale\n stale_reason: other_engine\n format_version: null\n \
3175 content:\n bytes: 5\n state: stale\n stale_reason: older_format\n \
3176 format_version: 4\n"
3177 )
3178 );
3179 assert!(render_cache_status(&leftovers[1..], CacheScope::All, Format::Yaml).ends_with(
3180 "state: leftover\n leftover_kind: orphaned_content\n content: null\n"
3181 ));
3182 }
3183
3184 #[test]
3190 fn cache_status_carries_every_stored_tier_identity() {
3191 use crate::{CacheScope, CacheState, ContentInfo, ContentState, ContentStatus};
3192
3193 let snapshot = small_snapshot_identity();
3194 let content = crate::ContentTierIdentity {
3195 entries: snapshot.entries,
3196 analysis: crate::content::AnalysisSet::NONE.with_lines(),
3197 provenance: crate::AnalyzerProvenance {
3198 options_fingerprint: crate::content::OptionsFingerprint(5),
3199 analyzers: vec![(
3200 crate::content::CONTENT_BASIC,
3201 crate::content::AnalyzerVersion(1),
3202 )],
3203 },
3204 };
3205 let status = crate::CacheStatus {
3206 path: PathBuf::from("e.fdu"),
3207 bytes: 50,
3208 content: Some(ContentStatus {
3209 bytes: 9,
3210 state: ContentState::Current(ContentInfo { identity: content, records: 2 }),
3211 }),
3212 state: CacheState::Current(crate::SnapshotInfo {
3213 root: PathBuf::from("/tree"),
3214 identity: snapshot,
3215 entries: 3,
3216 }),
3217 };
3218 let entries = "{\"engine\": 1, \"max_depth\": null, \"follow_symlinks\": false, \
3219 \"one_filesystem\": true, \"hidden_fingerprint\": 2, \"exclude_special\": false, \
3220 \"population\": \"include\", \"control_fingerprint\": 0, \
3221 \"type_rules_fingerprint\": 3, \"reducers_fingerprint\": 4}";
3222 assert_eq!(
3223 render_cache_status(std::slice::from_ref(&status), CacheScope::Root, Format::Jsonl),
3224 format!(
3225 "{{\"schema\": \"fdu.cache/3\"}}\n\
3226 {{\"path\": \"e.fdu\", \"bytes\": 50, \"state\": \"current\", \"root\": \"/tree\", \
3227 \"entries\": 3, \"identity\": {{\"entries\": {entries}, \"ignore_rules\": \
3228 {{\"limits\": {{\"budget\": 10, \"line_limit\": null}}}}}}, \"content\": {{\"bytes\": 9, \
3229 \"state\": \"current\", \"records\": 2, \"identity\": {{\"entries\": {entries}, \
3230 \"analyze\": [\"lines\"], \"options_fingerprint\": 5, \
3231 \"analyzers\": [{{\"id\": \"content-basic-v1\", \"version\": 1}}]}}}}}}"
3232 )
3233 );
3234 let entries = "\n engine: 1\n max_depth: null\n \
3235 follow_symlinks: false\n one_filesystem: true\n \
3236 hidden_fingerprint: 2\n exclude_special: false\n \
3237 population: include\n control_fingerprint: 0\n \
3238 type_rules_fingerprint: 3\n reducers_fingerprint: 4";
3239 assert_eq!(
3240 render_cache_status(std::slice::from_ref(&status), CacheScope::Root, Format::Yaml),
3241 format!(
3242 "schema: fdu.cache/3\ncaches:\n -\n path: e.fdu\n bytes: 50\n state: current\n \
3243 root: /tree\n entries: 3\n identity:\n entries:{}\n \
3244 ignore_rules:\n limits:\n budget: 10\n line_limit: null\n \
3245 content:\n bytes: 9\n state: current\n records: 2\n identity:\n \
3246 entries:{entries}\n analyze:\n - lines\n \
3247 options_fingerprint: 5\n analyzers:\n -\n id: content-basic-v1\n \
3248 version: 1\n",
3249 entries.replace("\n ", "\n")
3250 )
3251 );
3252 let mut large = status.clone();
3253 large.bytes = 60_696_111;
3254 large.content.as_mut().expect("fixture has a content sidecar").bytes = 120_783_062;
3255 assert_eq!(
3256 render_cache_status(std::slice::from_ref(&large), CacheScope::Root, Format::Text),
3257 "e.fdu 3 entries, 57 MiB metadata, 115 MiB content /tree"
3258 );
3259 let colored = render_cache_status_with_options(
3260 std::slice::from_ref(&large),
3261 CacheScope::Root,
3262 Format::Text,
3263 RenderOptions { color: true, ..RenderOptions::default() },
3264 );
3265 assert_eq!(
3266 strip_ansi(&colored),
3267 render_cache_status(&[large], CacheScope::Root, Format::Text)
3268 );
3269 let zero = render_cache_status_with_options(
3270 std::slice::from_ref(&status),
3271 CacheScope::Root,
3272 Format::Text,
3273 RenderOptions { color: true, ..RenderOptions::default() },
3274 );
3275 assert!(!zero.contains("\x1b["), "positive sub-GiB sizes stay unstyled: {zero:?}");
3276 let mut zero_and_gib = status.clone();
3277 zero_and_gib.bytes = 0;
3278 zero_and_gib.content.as_mut().expect("fixture has a content sidecar").bytes = 1 << 30;
3279 let colored = render_cache_status_with_options(
3280 std::slice::from_ref(&zero_and_gib),
3281 CacheScope::Root,
3282 Format::Text,
3283 RenderOptions { color: true, ..RenderOptions::default() },
3284 );
3285 assert!(colored.contains(&paint("0 B", STYLE_DETAIL, true)), "{colored:?}");
3286 assert!(colored.contains(&paint("1.0 GiB", AnsiStyle::new().bold(), true)), "{colored:?}");
3287 assert_eq!(strip_ansi(&colored), "e.fdu 3 entries, 0 B metadata, 1.0 GiB content /tree");
3288 for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3289 assert_eq!(
3290 render_cache_status_with_options(
3291 std::slice::from_ref(&status),
3292 CacheScope::Root,
3293 format,
3294 RenderOptions { color: true, ..RenderOptions::default() },
3295 ),
3296 render_cache_status(std::slice::from_ref(&status), CacheScope::Root, format)
3297 );
3298 }
3299 let stale_content = crate::CacheStatus {
3301 content: Some(ContentStatus {
3302 bytes: 9,
3303 state: ContentState::Stale(crate::StaleReason::OtherEngine),
3304 }),
3305 ..status
3306 };
3307 assert_eq!(
3308 render_cache_status(&[stale_content], CacheScope::Root, Format::Text),
3309 "e.fdu 3 entries, 50 B metadata, 9 B stale content /tree"
3310 );
3311 }
3312
3313 #[test]
3318 fn the_cache_schema_constant_is_the_versioning_promise() {
3319 assert_eq!(CACHE_SCHEMA, "fdu.cache/3");
3320 for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3321 let rendered = render_cache_status(&[], crate::CacheScope::All, format);
3322 assert!(rendered.contains(CACHE_SCHEMA), "{format:?} carries no schema: {rendered}");
3323 }
3324 }
3325
3326 #[cfg(all(unix, feature = "watch"))]
3327 #[test]
3328 fn cache_and_change_rows_preserve_non_unicode_raw_paths() {
3329 use std::ffi::OsString;
3330 use std::os::unix::ffi::OsStringExt;
3331
3332 let path = PathBuf::from(OsString::from_vec(vec![b'n', 0x80]));
3333 let status = crate::CacheStatus {
3334 path: path.clone(),
3335 bytes: 3,
3336 content: None,
3337 state: crate::CacheState::Unrecognized,
3338 };
3339 for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3340 let rendered =
3341 render_cache_status(std::slice::from_ref(&status), crate::CacheScope::All, format);
3342 assert!(rendered.contains("path_raw"), "{format:?}: {rendered}");
3343 assert!(rendered.contains("6e80"), "{format:?}: {rendered}");
3344 }
3345
3346 let change = crate::Change {
3347 path,
3348 kind: crate::ChangeKind::Remove,
3349 entry_kind: None,
3350 bytes: None,
3351 allocated: None,
3352 mtime_ns: None,
3353 ignored: None,
3354 clock: 1,
3355 };
3356 for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3357 let rendered = render_change(&change, format);
3358 assert!(rendered.contains("path_raw"), "{format:?}: {rendered}");
3359 assert!(rendered.contains("6e80"), "{format:?}: {rendered}");
3360 }
3361 }
3362
3363 #[test]
3369 fn a_bound_states_itself_and_states_it_accurately() {
3370 let report = fixture(&[ViewSpec::Files]);
3371 let Section::Files { rows, total, .. } = &report.sections[0] else {
3372 panic!("expected a files section");
3373 };
3374 let full = rows.len();
3375 assert_eq!(*total, full, "an unbounded view drops nothing");
3376 assert!(!render(&report, Format::Text, false).contains("--limit all"), "and says nothing");
3377 assert!(render(&report, Format::Json, false).contains("\"bound\": null"));
3378
3379 let mut query = Query { views: vec![ViewSpec::Files], ..Query::default() };
3381 query.selection.limit = Some(Bound::Limit(1));
3382 let bounded = fixture_for(&query);
3383 let Section::Files { rows, total, .. } = &bounded.sections[0] else {
3384 panic!("expected a files section");
3385 };
3386 assert_eq!(rows.len(), 1);
3387 assert_eq!(*total, full, "the total is what there was, not what was kept");
3388
3389 let text = render(&bounded, Format::Text, false);
3390 assert!(text.contains(&format!("(1 of {full}")), "the header states the bound: {text}");
3391 assert!(!text.contains("--limit"), "actionable guidance stays out of the body: {text}");
3392 assert_eq!(
3393 report_tips(&bounded),
3394 vec!["tip: show more rows: limit=all"],
3395 "the shared epilogue names the row remedy once"
3396 );
3397 let json = render(&bounded, Format::Json, false);
3398 assert!(json.contains(&format!("\"shown\": 1, \"total\": {full}")), "{json:.200}");
3399 let yaml = render(&bounded, Format::Yaml, false);
3400 assert!(yaml.contains("shown: 1"), "{yaml:.200}");
3401 }
3402
3403 fn strip_ansi(text: &str) -> String {
3410 let mut out = String::with_capacity(text.len());
3411 let mut chars = text.chars();
3412 while let Some(c) = chars.next() {
3413 if c == '\u{1b}' {
3414 for escape in chars.by_ref() {
3415 if escape.is_ascii_alphabetic() {
3416 break;
3417 }
3418 }
3419 } else {
3420 out.push(c);
3421 }
3422 }
3423 out
3424 }
3425
3426 #[test]
3427 fn colour_never_changes_the_layout_of_any_view() {
3428 for view in [
3429 ViewSpec::Tree,
3430 ViewSpec::Extensions,
3431 ViewSpec::Types,
3432 ViewSpec::Families,
3433 ViewSpec::Languages,
3434 ViewSpec::Files,
3435 ViewSpec::Summary,
3436 ] {
3437 let report = fixture(&[view]);
3438 let plain = render(&report, Format::Text, false);
3439 let coloured = render(&report, Format::Text, true);
3440 let styles_a_label = !matches!(view, ViewSpec::Files | ViewSpec::Summary);
3443 assert_eq!(
3444 plain != coloured,
3445 styles_a_label,
3446 "{view:?} disagrees with whether it styles a label"
3447 );
3448 assert_eq!(
3449 strip_ansi(&coloured).replace('·', "░").replace('▒', "█"),
3450 plain,
3451 "{view:?} lays out differently once colour is on"
3452 );
3453 }
3454 }
3455
3456 #[test]
3458 fn a_label_cell_is_measured_on_visible_text() {
3459 let plain = label_cell("md", 6, STYLE_CATEGORY, false);
3460 let coloured = label_cell("md", 6, STYLE_CATEGORY, true);
3461 assert_eq!(plain, "md ", "four spaces of padding");
3462 assert!(coloured.starts_with('\u{1b}'), "the label is styled");
3463 assert!(coloured.ends_with(" "), "and padded by the same four: {coloured:?}");
3464 assert_eq!(label_cell("verylonglabel", 4, STYLE_CATEGORY, false), "verylonglabel");
3466 }
3467
3468 const ALL_TEST_VIEWS: [ViewSpec; 11] = [
3470 ViewSpec::List,
3471 ViewSpec::Tree,
3472 ViewSpec::Types,
3473 ViewSpec::Extensions,
3474 ViewSpec::Families,
3475 ViewSpec::Languages,
3476 ViewSpec::Documents,
3477 ViewSpec::Files,
3478 ViewSpec::Largest,
3479 ViewSpec::Recent,
3480 ViewSpec::Summary,
3481 ];
3482
3483 struct SchemaCheck<S> {
3485 inner: S,
3486 expected: Vec<&'static str>,
3487 next: usize,
3488 depth: usize,
3489 }
3490
3491 impl<S: Sink> SchemaCheck<S> {
3492 fn report(inner: S, lossy: bool, sections: bool) -> Self {
3493 let fields = &REPORT_FIELDS;
3494 let ordered = [
3495 fields.schema,
3496 fields.generator,
3497 fields.root,
3498 fields.root_raw,
3499 fields.age_reference_ns,
3500 fields.request,
3501 fields.status,
3502 fields.provenance,
3503 fields.ignore_rules,
3504 fields.analysis,
3505 fields.reports,
3506 ];
3507 let expected = ordered
3508 .into_iter()
3509 .filter_map(|field| {
3510 let present = match field.presence {
3511 Presence::Always | Presence::Nullable => true,
3512 Presence::WhenLossy => lossy,
3513 Presence::WhenSet => sections,
3514 Presence::WhenAnalyzer(_) => panic!("envelope has no analyzer-owned field"),
3515 };
3516 present.then_some(field.name)
3517 })
3518 .collect();
3519 Self { inner, expected, next: 0, depth: 0 }
3520 }
3521 }
3522
3523 impl<S: Sink> Sink for SchemaCheck<S> {
3524 type Output = S::Output;
3525
3526 fn event(&mut self, event: Event<'_>) {
3527 match event {
3528 Event::BeginMap(_) | Event::BeginSeq(_) => self.depth += 1,
3529 Event::EndMap | Event::EndSeq => self.depth -= 1,
3530 Event::Key(name) if self.depth == 1 => {
3531 assert_eq!(
3532 Some(&name),
3533 self.expected.get(self.next),
3534 "wire field order/presence"
3535 );
3536 self.next += 1;
3537 }
3538 Event::Key(_) | Event::Scalar(_) => {}
3539 }
3540 self.inner.event(event);
3541 }
3542
3543 fn finish(self) -> Self::Output {
3544 assert_eq!(self.depth, 0, "unclosed collection");
3545 assert_eq!(self.next, self.expected.len(), "required field missing");
3546 self.inner.finish()
3547 }
3548 }
3549
3550 #[test]
3551 fn report_walk_obeys_declared_field_order_and_presence_for_every_writer() {
3552 for view in [ViewSpec::Summary, ViewSpec::Documents] {
3553 let report = fixture(&[view]);
3554 for sections in [false, true] {
3555 let mut json = SchemaCheck::report(JsonSink::pretty(), false, sections);
3556 emit_report(&mut json, &report, sections);
3557 assert!(!json.finish().is_empty());
3558 let mut line = SchemaCheck::report(JsonSink::line(), false, sections);
3559 emit_report(&mut line, &report, sections);
3560 assert!(!line.finish().is_empty());
3561 let mut yaml = SchemaCheck::report(YamlSink::new(), false, sections);
3562 emit_report(&mut yaml, &report, sections);
3563 assert!(!yaml.finish().is_empty());
3564 }
3565 }
3566 }
3567
3568 #[test]
3569 #[should_panic(expected = "required field missing")]
3570 fn schema_check_rejects_a_missing_required_field() {
3571 let mut check = SchemaCheck::report(JsonSink::line(), false, true);
3572 check.event(Event::BeginMap(Shape::Inline));
3573 check.event(Event::EndMap);
3574 check.finish();
3575 }
3576
3577 #[test]
3578 fn list_formats_preserve_the_default_tree_and_expose_flat_subtree_metrics() {
3579 let legacy = fixture(&[ViewSpec::Tree]);
3580 let list = fixture(&[ViewSpec::List]);
3581 assert_eq!(
3582 super::render(&legacy, Format::Text, false).expect("tree"),
3583 super::render(&list, Format::Tree, false).expect("list tree")
3584 );
3585 assert!(
3586 super::render(&list, Format::Paths, false).is_err(),
3587 "folding cannot silently become an inventory"
3588 );
3589 let mut rejected = Vec::new();
3590 assert_eq!(
3591 write(&list, Format::Paths, false, &mut rejected)
3592 .expect_err("folded projection")
3593 .kind(),
3594 io::ErrorKind::InvalidInput
3595 );
3596 assert!(rejected.is_empty(), "validate before writing any bytes");
3597 let flat = fixture_for(&Query {
3598 views: vec![ViewSpec::List],
3599 format: Format::Paths,
3600 selection: Selection {
3601 kinds: vec![EntryKind::Dir],
3602 size: SizeMetric::Apparent,
3603 ..Selection::default()
3604 },
3605 ..Query::default()
3606 });
3607 assert_eq!(super::render(&flat, Format::Paths, false).expect("paths"), "src\n");
3608 assert!(super::render(&flat, Format::Long, false).expect("long").contains("100 B"));
3609 assert!(super::render(&flat, Format::Tree, false).is_err());
3610 let Section::Files { rows, .. } = &flat.sections[0] else { panic!("flat list") };
3611 assert_eq!((rows[0].files, rows[0].dirs, rows[0].mtime_ns), (Some(1), Some(0), 10));
3612 assert_eq!(rows[0].complete, Some(true));
3613 assert!(
3614 super::render(&flat, Format::Json, false).expect("json").contains("\"complete\": true")
3615 );
3616 assert_eq!(
3617 rows[0].age_ns,
3618 flat.age_reference_ns.map(|reference| i128::from(reference) - 10)
3619 );
3620 for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3621 let wire = super::render(&flat, format, false).expect("serialization");
3622 assert!(wire.contains("age_reference_ns"));
3623 assert!(wire.contains("age_ns"));
3624 }
3625 assert_eq!(human_age(Some(-1)), "-0s");
3626 assert_eq!(human_age(Some(30 * 86400 * 1_000_000_000)), "30d");
3627 assert_eq!(human_age(None), "unknown");
3628 assert_eq!(flat_path(Path::new("a\nb\tc")), "a\\nb\\tc");
3629 assert_eq!(flat_path(Path::new("d/a\\b")), "d/a\\b");
3632 let mut stale = flat.clone();
3633 stale.provenance.source = ReportSource::CacheOnly;
3634 stale.provenance.freshness = Freshness::Stale;
3635 stale.scope.max_depth = Some(2);
3636 let notes = flat_diagnostics(&stale).join("\n");
3637 assert!(notes.contains("warn: stale answer"), "{notes}");
3638 assert!(!notes.contains("note: cache-only"), "the warning states it once: {notes}");
3639 assert!(notes.contains("freshness: stale"));
3640 assert!(notes.contains("scan scope limited to depth 2"));
3641 assert_eq!(super::render(&stale, Format::Paths, false).expect("paths"), "src\n");
3642 }
3643
3644 #[test]
3648 fn only_a_cache_only_answer_warns_that_it_is_stale_in_the_surface_vocabulary() {
3649 let tree = Query { views: vec![ViewSpec::Tree], ..Query::default() };
3650 let mut verified = fixture_for(&tree);
3651 for source in [ReportSource::ColdScan, ReportSource::WarmRevalidate] {
3652 verified.provenance.source = source;
3653 assert!(report_warnings(&verified).is_empty(), "{source:?} is verified");
3654 assert!(!diagnostics(&verified).iter().any(|line| line.starts_with("warn:")));
3655 }
3656 for (axes, option) in [
3657 (&crate::query::AxisNames::FLAGS, "--stale-ok"),
3658 (&crate::query::AxisNames::FIELDS, "stale_ok"),
3659 ] {
3660 let mut stale = fixture_for(&Query {
3661 axes,
3662 selection: Selection { depth: Some(Bound::Limit(0)), ..Selection::default() },
3663 ..tree.clone()
3664 });
3665 stale.provenance.source = ReportSource::CacheOnly;
3666 stale.provenance.freshness = Freshness::Stale;
3667 let warning = format!(
3668 "warn: stale answer: served from the snapshot without filesystem verification; \
3669 drop {option} for a fresh answer"
3670 );
3671 assert_eq!(report_warnings(&stale), std::slice::from_ref(&warning));
3672 assert!(!report_notes(&stale).contains(&warning), "a note would be quieted");
3673 let lines = diagnostics(&stale);
3674 let at = lines.iter().position(|line| *line == warning).expect("warning");
3675 assert!(lines[..at].iter().all(|line| line.starts_with("note:")), "{lines:?}");
3676 assert!(lines[at + 1..].iter().all(|line| line.starts_with("tip:")), "{lines:?}");
3677 assert!(lines.first().is_some_and(|line| line.starts_with("note:")), "{lines:?}");
3678 assert!(lines.last().is_some_and(|line| line.starts_with("tip:")), "{lines:?}");
3679 let rendered = super::render(&stale, Format::Text, false).expect("text");
3680 assert!(!rendered.contains("warn:"), "stdout carries only the answer: {rendered}");
3681 }
3682 }
3683
3684 #[test]
3685 fn signed_ages_preserve_exact_endpoints_in_streaming_machine_formats() {
3686 let mut report = fixture_for(&Query {
3687 views: vec![ViewSpec::List],
3688 format: Format::Json,
3689 ..Query::default()
3690 });
3691 for (reference, modified) in [(i64::MAX, i64::MIN), (i64::MIN, i64::MAX)] {
3692 let age = i128::from(reference) - i128::from(modified);
3693 report.age_reference_ns = Some(reference);
3694 let Section::Files { rows, .. } = &mut report.sections[0] else { panic!("flat rows") };
3695 rows[0].mtime_ns = modified;
3696 rows[0].age_ns = Some(age);
3697 for format in [Format::Json, Format::Jsonl, Format::Yaml] {
3698 let rendered = super::render(&report, format, false).expect("machine format");
3699 assert!(rendered.contains(&age.to_string()), "{format:?} lost exact signed age");
3700 let mut streamed = Vec::new();
3701 write(&report, format, false, &mut streamed).expect("streaming writer");
3702 assert_eq!(streamed, rendered.as_bytes());
3703 }
3704 }
3705 }
3706
3707 fn fixture(views: &[ViewSpec]) -> Report {
3708 fixture_for(&Query { views: views.to_vec(), ..Query::default() })
3709 }
3710
3711 fn fixture_for(query: &Query) -> Report {
3712 let mut index = Index::new_with_scope("/root", ScanScope::default());
3713 if query.views.contains(&ViewSpec::Documents) {
3718 index.prepare_content_analysis(crate::content::AnalysisRequest {
3719 profile: crate::content::AnalysisSet::NONE.with_words(),
3720 ..crate::content::AnalysisRequest::default()
3721 });
3722 }
3723 index
3724 .apply(&Observation::new(vec![
3725 Op::Upsert {
3726 path: PathBuf::from("src"),
3727 kind: EntryKind::Dir,
3728 attrs: Attrs::default(),
3729 },
3730 Op::Upsert {
3731 path: PathBuf::from("src/main.rs"),
3732 kind: EntryKind::File,
3733 attrs: attrs(100, 10),
3734 },
3735 Op::Upsert {
3736 path: PathBuf::from("notes.md"),
3737 kind: EntryKind::File,
3738 attrs: attrs(20, 20),
3739 },
3740 ]))
3741 .expect("apply");
3742 report(
3743 &index,
3744 &crate::test_support::read_of(&index, query.clone()),
3745 &Provenance {
3746 scan_started_at: Some(UNIX_EPOCH + Duration::from_secs(1_786_386_151)),
3747 generated_at: UNIX_EPOCH + Duration::from_secs(1_786_386_152),
3748 source: ReportSource::ColdScan,
3749 complete: true,
3750 errors: Vec::new(),
3751 },
3752 )
3753 .expect("report")
3754 }
3755
3756 fn is_view_header_line(line: &str) -> bool {
3761 !line.is_empty() && line.chars().all(|c| c.is_ascii_uppercase())
3762 }
3763
3764 fn is_valid_json(text: &str) -> bool {
3769 let (mut depth, mut in_string, mut escaped) = (0i32, false, false);
3770 for ch in text.chars() {
3771 if in_string {
3772 match ch {
3773 _ if escaped => escaped = false,
3774 '\\' => escaped = true,
3775 '"' => in_string = false,
3776 _ => {}
3777 }
3778 continue;
3779 }
3780 match ch {
3781 '"' => in_string = true,
3782 '{' | '[' => depth += 1,
3783 '}' | ']' => {
3784 depth -= 1;
3785 if depth < 0 {
3786 return false;
3787 }
3788 }
3789 _ => {}
3790 }
3791 }
3792 depth == 0 && !in_string
3793 }
3794
3795 fn compact_json(text: &str) -> String {
3800 let mut compact = String::with_capacity(text.len());
3801 let (mut in_string, mut escaped) = (false, false);
3802 for ch in text.chars() {
3803 if in_string {
3804 compact.push(ch);
3805 match ch {
3806 _ if escaped => escaped = false,
3807 '\\' => escaped = true,
3808 '"' => in_string = false,
3809 _ => {}
3810 }
3811 } else if ch == '"' {
3812 in_string = true;
3813 compact.push(ch);
3814 } else if !ch.is_ascii_whitespace() {
3815 compact.push(ch);
3816 }
3817 }
3818 compact
3819 }
3820
3821 #[test]
3826 fn every_view_renders_in_every_format() {
3827 for view in ALL_TEST_VIEWS {
3828 let report = fixture(&[view]);
3829 for format in [Format::Text, Format::Json, Format::Jsonl, Format::Yaml] {
3830 let rendered = render(&report, format, false);
3831 assert!(!rendered.trim().is_empty(), "{view:?} in {format:?} rendered nothing");
3832 if format != Format::Text {
3833 assert!(
3834 rendered.contains("\"schema\"") || rendered.contains("schema:"),
3835 "{view:?} as {format:?} carries no schema: {rendered:.120}"
3836 );
3837 }
3838 }
3839 }
3840 }
3841
3842 #[test]
3843 fn text_tree_restores_compact_bars_and_keeps_structural_indent_in_the_name_column() {
3844 let apparent = Query {
3847 views: vec![ViewSpec::Tree],
3848 selection: crate::query::Selection {
3849 size: crate::query::SizeMetric::Apparent,
3850 ..crate::query::Selection::default()
3851 },
3852 ..Query::default()
3853 };
3854 let text = render(&fixture_for(&apparent), Format::Text, false);
3855 assert_eq!(
3856 text,
3857 concat!(
3858 "██████████ 100% 120 B . 2 files\n",
3859 "████████░░ 83% 100 B src/ 1 file\n",
3860 "████████░░ 83% 100 B main.rs\n",
3861 "██░░░░░░░░ 17% 20 B notes.md\n",
3862 )
3863 );
3864
3865 let lines: Vec<&str> = text.lines().collect();
3866 assert_eq!(lines[0].find("120 B"), lines[1].find("100 B"));
3867 assert_eq!(lines[0].find('█'), lines[1].find('█'));
3868 }
3869
3870 #[test]
3871 fn language_text_uses_human_names_and_aligns_suffixes_with_color() {
3872 let mut index = Index::new_with_scope("/root", ScanScope::default());
3873 index
3874 .apply(&Observation::new(vec![
3875 Op::Upsert {
3876 path: PathBuf::from("main.cpp"),
3877 kind: EntryKind::File,
3878 attrs: attrs(100, 10),
3879 },
3880 Op::Upsert {
3881 path: PathBuf::from("main.js"),
3882 kind: EntryKind::File,
3883 attrs: attrs(100, 20),
3884 },
3885 ]))
3886 .expect("apply");
3887 let report = report(
3888 &index,
3889 &crate::test_support::read_of(
3890 &index,
3891 Query { views: vec![ViewSpec::Languages], ..Query::default() },
3892 ),
3893 &Provenance {
3894 scan_started_at: None,
3895 generated_at: UNIX_EPOCH,
3896 source: ReportSource::ColdScan,
3897 complete: true,
3898 errors: Vec::new(),
3899 },
3900 )
3901 .expect("report");
3902
3903 let plain = render(&report, Format::Text, false);
3904 assert!(plain.contains("C++"), "{plain}");
3905 assert!(plain.contains("JavaScript"), "{plain}");
3906 let plain_suffixes = plain
3907 .lines()
3908 .map(|line| line.find("1 file").expect("file count suffix"))
3909 .collect::<Vec<_>>();
3910 assert_eq!(plain_suffixes[0], plain_suffixes[1], "{plain}");
3911
3912 let colored = render(&report, Format::Text, true);
3913 let colored_suffixes = colored
3914 .lines()
3915 .map(|line| line.find("1 file").expect("colored file count suffix"))
3916 .collect::<Vec<_>>();
3917 assert_eq!(colored_suffixes[0], colored_suffixes[1], "{colored:?}");
3918
3919 let json = render(&report, Format::Json, false);
3920 assert!(json.contains("\"id\": \"cpp\""), "{json}");
3921 assert!(json.contains("\"id\": \"javascript\""), "{json}");
3922 assert!(!json.contains("\"id\": \"C++\""), "{json}");
3923 assert!(!json.contains("\"id\": \"JavaScript\""), "{json}");
3924 }
3925
3926 #[test]
3927 fn text_labels_a_percentage_that_is_not_a_byte_share() {
3928 let mut languages = fixture(&[ViewSpec::Languages]);
3929 if let Section::Metrics { summary, .. } = &mut languages.sections[0] {
3930 summary.share_metric = ShareMetric::CodeLines;
3931 } else {
3932 panic!("languages should be a metric section");
3933 }
3934 let text = render(&languages, Format::Text, false);
3935 assert!(text.starts_with("Percentage column: code lines\n"), "{text}");
3936
3937 let Section::Metrics { summary, .. } = &mut languages.sections[0] else {
3938 unreachable!("languages should stay a metric section");
3939 };
3940 summary.share_metric = ShareMetric::AllocatedBytes;
3941 let text = render(&languages, Format::Text, false);
3942 assert!(!text.contains("Percentage column:"), "{text}");
3943
3944 let mut documents = fixture(&[ViewSpec::Documents]);
3945 let Section::Metrics { summary, .. } = &mut documents.sections[0] else {
3946 panic!("documents should be a metric section");
3947 };
3948 summary.share_metric = ShareMetric::DocumentWords;
3949 let text = render(&documents, Format::Text, false);
3950 assert!(text.starts_with("Percentage column: document words\n"), "{text}");
3951 }
3952
3953 #[test]
3954 fn json_output_is_well_formed_for_every_view() {
3955 for view in [
3956 ViewSpec::Tree,
3957 ViewSpec::Extensions,
3958 ViewSpec::Types,
3959 ViewSpec::Families,
3960 ViewSpec::Languages,
3961 ViewSpec::Documents,
3962 ViewSpec::Files,
3963 ViewSpec::Summary,
3964 ] {
3965 let json = render(&fixture(&[view]), Format::Json, false);
3966 assert!(is_valid_json(&json), "unbalanced JSON for {view:?}:\n{json}");
3967 }
3968 let all = render(
3969 &fixture(&[
3970 ViewSpec::Tree,
3971 ViewSpec::Extensions,
3972 ViewSpec::Types,
3973 ViewSpec::Files,
3974 ViewSpec::Summary,
3975 ]),
3976 Format::Json,
3977 false,
3978 );
3979 assert!(is_valid_json(&all), "unbalanced JSON for a multi-view report:\n{all}");
3980 }
3981
3982 #[test]
3983 fn streaming_machine_writers_match_string_rendering() {
3984 struct Fails;
3985 impl std::io::Write for Fails {
3986 fn write(&mut self, _buffer: &[u8]) -> std::io::Result<usize> {
3987 Err(std::io::Error::other("closed"))
3988 }
3989
3990 fn flush(&mut self) -> std::io::Result<()> {
3991 Ok(())
3992 }
3993 }
3994
3995 let report = fixture(&[
3996 ViewSpec::Tree,
3997 ViewSpec::Extensions,
3998 ViewSpec::Types,
3999 ViewSpec::Files,
4000 ViewSpec::Summary,
4001 ]);
4002 for format in [Format::Json, Format::Jsonl, Format::Yaml] {
4003 let expected = render(&report, format, false);
4004 let mut streamed = Vec::new();
4005 write(&report, format, false, &mut streamed).expect("stream report");
4006 assert_eq!(streamed, expected.as_bytes(), "{format:?} bytes differ");
4007 }
4008
4009 let error = write(&report, Format::Json, false, &mut Fails).expect_err("writer fails");
4010 assert_eq!(error.kind(), std::io::ErrorKind::Other);
4011 }
4012
4013 #[test]
4014 fn streaming_tree_walk_handles_many_siblings_without_collecting_output() {
4015 #[derive(Default)]
4016 struct Count(u64);
4017 impl std::io::Write for Count {
4018 fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
4019 self.0 = self.0.saturating_add(buffer.len() as u64);
4020 Ok(buffer.len())
4021 }
4022
4023 fn flush(&mut self) -> std::io::Result<()> {
4024 Ok(())
4025 }
4026 }
4027
4028 let mut report = fixture(&[ViewSpec::Tree]);
4029 let Section::Tree { root: Some(root), .. } = &mut report.sections[0] else {
4030 panic!("tree fixture must contain a tree");
4031 };
4032 let template = root.children[0].clone();
4033 root.children = (0..10_000)
4034 .map(|index| {
4035 let mut child = template.clone();
4036 child.name = format!("child-{index}");
4037 child.path = PathBuf::from(&child.name);
4038 child
4039 })
4040 .collect();
4041
4042 let mut output = Count::default();
4043 write(&report, Format::Json, false, &mut output).expect("stream wide report");
4044 assert!(output.0 > 1_000_000, "wide fixture must exercise substantial output");
4045 }
4046
4047 #[test]
4048 fn nested_json_separates_siblings_without_a_trailing_comma() {
4049 let mut index = Index::new_with_scope("/root", ScanScope::default());
4053 index
4054 .apply(&Observation::new(vec![
4055 Op::Upsert {
4056 path: PathBuf::from("a"),
4057 kind: EntryKind::Dir,
4058 attrs: Attrs::default(),
4059 },
4060 Op::Upsert {
4061 path: PathBuf::from("b"),
4062 kind: EntryKind::Dir,
4063 attrs: Attrs::default(),
4064 },
4065 Op::Upsert {
4066 path: PathBuf::from("c"),
4067 kind: EntryKind::Dir,
4068 attrs: Attrs::default(),
4069 },
4070 Op::Upsert {
4071 path: PathBuf::from("a/inner"),
4072 kind: EntryKind::Dir,
4073 attrs: Attrs::default(),
4074 },
4075 Op::Upsert {
4076 path: PathBuf::from("a/other"),
4077 kind: EntryKind::Dir,
4078 attrs: Attrs::default(),
4079 },
4080 ]))
4081 .expect("apply");
4082 let report = report(
4083 &index,
4084 &crate::test_support::read_of(
4085 &index,
4086 Query {
4087 selection: Selection {
4088 depth: Some(Bound::All),
4089 min_share: Some(crate::query::ShareThreshold::parse("0%").expect("share")),
4090 ..Selection::default()
4091 },
4092 views: vec![ViewSpec::Tree],
4093 ..Query::default()
4094 },
4095 ),
4096 &Provenance {
4097 scan_started_at: None,
4098 generated_at: UNIX_EPOCH,
4099 source: ReportSource::ColdScan,
4100 complete: true,
4101 errors: Vec::new(),
4102 },
4103 )
4104 .expect("report");
4105
4106 let json = render(&report, Format::Json, false);
4107 assert!(is_valid_json(&json), "{json}");
4108 assert!(!json.contains("}{"), "siblings must be separated:\n{json}");
4109 assert!(!json.contains(",]"), "no trailing comma before a close:\n{json}");
4110 assert!(!json.contains("[,"), "no leading comma after an open:\n{json}");
4111 for name in ["\"a\"", "\"b\"", "\"c\"", "\"inner\"", "\"other\""] {
4113 assert!(json.contains(name), "missing {name} in:\n{json}");
4114 }
4115 }
4116
4117 #[test]
4118 fn jsonl_emits_one_document_per_line() {
4119 let rendered =
4120 render(&fixture(&[ViewSpec::Extensions, ViewSpec::Summary]), Format::Jsonl, false);
4121 let lines: Vec<&str> = rendered.lines().collect();
4122 assert_eq!(lines.len(), 3, "one envelope plus one line per section");
4123 for line in &lines {
4124 assert!(is_valid_json(line), "line is not a JSON document: {line}");
4125 }
4126 assert!(lines[0].contains("\"schema\""), "the envelope carries provenance");
4127 }
4128
4129 #[test]
4130 fn machine_output_carries_the_schema_and_provenance() {
4131 let json = render(&fixture(&[ViewSpec::Summary]), Format::Json, false);
4132 assert!(json.contains("\"schema\": \"fdu.report/10\""));
4133 assert!(json.contains("\"request\": {"));
4134 assert!(json.contains("\"status\": {"));
4135 assert!(json.contains("\"provenance\": {"));
4136 assert!(json.contains("\"source\": \"cold_scan\""));
4137 assert!(json.contains("\"complete\": true"));
4138 assert!(json.contains("\"scan_started_at\": \"2026-08-10T18:22:31.000000000Z\""), "{json}");
4140 assert!(json.contains("\"generated_at\": \"2026-08-10T18:22:32.000000000Z\""), "{json}");
4141 }
4142
4143 #[test]
4144 fn the_schema_constant_is_the_versioning_promise() {
4145 assert_eq!(REPORT_SCHEMA, "fdu.report/10");
4148 assert_eq!(CONTENT_REPORT_SCHEMA, REPORT_SCHEMA);
4149 }
4150
4151 #[test]
4154 fn every_format_states_the_ignore_rules_a_report_could_apply() {
4155 let unobserved = crate::test_support::not_observing_controls();
4156 let mut blind = Index::new_with_scope("/root", unobserved);
4157 blind
4158 .apply(&Observation::new(vec![Op::Upsert {
4159 path: PathBuf::from("a.txt"),
4160 kind: EntryKind::File,
4161 attrs: attrs(1, 1),
4162 }]))
4163 .expect("apply");
4164 let provenance = Provenance {
4165 scan_started_at: None,
4166 generated_at: UNIX_EPOCH,
4167 source: ReportSource::ColdScan,
4168 complete: true,
4169 errors: Vec::new(),
4170 };
4171 let query = Query { views: vec![ViewSpec::Summary], ..Query::default() };
4172 let blind_report =
4173 report(&blind, &crate::test_support::read_of(&blind, query.clone()), &provenance)
4174 .expect("report");
4175 assert!(render(&blind_report, Format::Json, false).contains("\"ignore_rules\": null"));
4176 assert!(render(&blind_report, Format::Yaml, false).contains("\nignore_rules: null\n"));
4177 assert!(blind_report.notes.is_empty());
4178
4179 let mut observed =
4180 Index::new_with_scope("/root", crate::test_support::observing_controls());
4181 let mut long_line = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
4182 long_line.push(b'\n');
4183 observed
4184 .apply(&Observation::new(vec![
4185 Op::Upsert {
4186 path: PathBuf::from("vendor"),
4187 kind: EntryKind::Dir,
4188 attrs: attrs(0, 1),
4189 },
4190 Op::ControlUpsert {
4191 path: PathBuf::from(".gitignore"),
4192 source: b"*.log\n".to_vec(),
4193 },
4194 Op::ControlUpsert { path: PathBuf::from("vendor/.gitignore"), source: long_line },
4195 ]))
4196 .expect("apply");
4197 let refused = Path::new("vendor").join(".gitignore");
4199 let refused = refused.to_string_lossy();
4200 let json = render(
4201 &report(
4202 &observed,
4203 &crate::test_support::read_of(&observed, query.clone()),
4204 &provenance,
4205 )
4206 .expect("report"),
4207 Format::Json,
4208 false,
4209 );
4210 let expected = format!(
4211 "\"ignore_rules\": {{\"limits\": {{\"budget\": 4194304, \"line_limit\": 16384}}, \
4212 \"applied\": 1, \"rules\": 1, \"refused\": 1, \"refusals\": [{{\"path\": {}, \"reason\": \
4213 \"line_limit\"}}]}}",
4214 quote(&refused)
4215 );
4216 assert!(compact_json(&json).contains(&compact_json(&expected)), "{json}");
4217 assert!(json.contains("\"complete\": true"), "a refusal is not an operational partial");
4218 let yaml = render(
4219 &report(
4220 &observed,
4221 &crate::test_support::read_of(&observed, query.clone()),
4222 &provenance,
4223 )
4224 .expect("report"),
4225 Format::Yaml,
4226 false,
4227 );
4228 let expected = format!(
4229 "ignore_rules:\n limits: {{budget: 4194304, line_limit: 16384}}\n applied: 1\n rules: 1\n \
4230 refused: 1\n refusals:\n -\n path: {}\n reason: line_limit\n",
4231 yaml_scalar(&refused)
4232 );
4233 assert!(yaml.contains(&expected), "{yaml}");
4234
4235 let flags = Query { axes: &crate::query::AxisNames::FLAGS, ..query.clone() };
4236 let candidate =
4237 report(&observed, &crate::test_support::read_of(&observed, flags), &provenance)
4238 .expect("report");
4239 let text = render(&candidate, Format::Text, false);
4240 assert!(
4241 !text.contains("note:") && !text.contains("tip:"),
4242 "stdout holds only formatted data"
4243 );
4244 let lines = diagnostics(&candidate);
4245 assert!(
4246 lines.iter().any(|line| line.starts_with("note: ignore classification incomplete:")
4247 && line.contains("vendor"))
4248 );
4249 assert!(lines.last().expect("remedy").contains("--gitignore-line-limit"));
4250 let fields =
4251 report(&observed, &crate::test_support::read_of(&observed, query), &provenance)
4252 .expect("report");
4253 assert!(report_tips(&fields).iter().any(|tip| tip.contains("control_line_limit")));
4254 assert!(!diagnostics(&fields).iter().any(|line| line.contains("--gitignore-line-limit")));
4255 }
4256
4257 #[test]
4261 fn every_format_carries_each_rows_ignored_share() {
4262 let build = |scope: ScanScope| {
4263 let mut index = Index::new_with_scope("/root", scope);
4264 let mut ops = vec![
4265 Op::Upsert {
4266 path: PathBuf::from("dist"),
4267 kind: EntryKind::Dir,
4268 attrs: Attrs::default(),
4269 },
4270 Op::Upsert {
4271 path: PathBuf::from("dist/a.gz"),
4272 kind: EntryKind::File,
4273 attrs: attrs(128, 10),
4274 },
4275 Op::Upsert {
4276 path: PathBuf::from("src"),
4277 kind: EntryKind::Dir,
4278 attrs: Attrs::default(),
4279 },
4280 Op::Upsert {
4281 path: PathBuf::from("src/b.rs"),
4282 kind: EntryKind::File,
4283 attrs: attrs(36, 20),
4284 },
4285 ];
4286 if scope.observes_controls() {
4287 ops.insert(
4288 0,
4289 Op::ControlUpsert {
4290 path: PathBuf::from(".gitignore"),
4291 source: b"dist/\n".to_vec(),
4292 },
4293 );
4294 }
4295 index.apply(&Observation::new(ops)).expect("apply");
4296 index
4297 };
4298 let provenance = Provenance {
4299 scan_started_at: None,
4300 generated_at: UNIX_EPOCH,
4301 source: ReportSource::ColdScan,
4302 complete: true,
4303 errors: Vec::new(),
4304 };
4305 let views = vec![ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions, ViewSpec::Files];
4306 let query = |ignored| Query {
4307 views: views.clone(),
4308 selection: Selection {
4309 ignored,
4310 size: SizeMetric::Apparent,
4311 limit: Some(Bound::All),
4312 ..Selection::default()
4313 },
4314 ..Query::default()
4315 };
4316
4317 let observed = build(crate::test_support::observing_controls());
4318 let text = render(
4319 &report(
4320 &observed,
4321 &crate::test_support::read_of(&observed, query(IgnoredEntries::Include)),
4322 &provenance,
4323 )
4324 .expect("report"),
4325 Format::Text,
4326 false,
4327 );
4328 assert_eq!(
4329 text,
4330 concat!(
4331 "SUMMARY\n",
4332 " 164 B 2 files, 2 directories (128 B gitignored)\n",
4333 "\n",
4334 "TREE\n",
4335 "██████████ 100% 164 B . 2 files (128 B gitignored)\n",
4336 "████████░░ 78% 128 B dist/ 1 file (128 B gitignored)\n",
4337 "████████░░ 78% 128 B a.gz (128 B gitignored)\n",
4338 "██░░░░░░░░ 22% 36 B src/ 1 file\n",
4339 "██░░░░░░░░ 22% 36 B b.rs\n",
4340 "\n",
4341 "EXTENSIONS\n",
4342 " 128 B .gz 1 file (128 B gitignored)\n",
4343 " 36 B .rs 1 file\n",
4344 "\n",
4345 "FILES\n",
4346 "dist\n",
4347 "dist/a.gz\n",
4348 "src\n",
4349 "src/b.rs\n",
4350 )
4351 .replace("dist/a.gz", &format!("dist{}a.gz", std::path::MAIN_SEPARATOR))
4352 .replace("src/b.rs", &format!("src{}b.rs", std::path::MAIN_SEPARATOR))
4353 );
4354 let dirs_only = IgnoredTally { files: 0, dirs: 1, bytes: 0, allocated: 0 };
4356 assert_eq!(
4357 ignored_suffix(Some(dirs_only), SizeMetric::Apparent, IgnoredEntries::Include, false),
4358 ""
4359 );
4360 let large_ignored = IgnoredTally { files: 1, dirs: 0, bytes: 1 << 30, allocated: 1 << 30 };
4361 let suffix = ignored_suffix(
4362 Some(large_ignored),
4363 SizeMetric::Apparent,
4364 IgnoredEntries::Include,
4365 true,
4366 );
4367 assert!(suffix.contains(&paint("1.0 GiB", STYLE_DETAIL.bold(), true)), "{suffix:?}");
4368 assert_eq!(strip_ansi(&suffix), " (1.0 GiB gitignored)");
4369 let only = render(
4370 &report(
4371 &observed,
4372 &crate::test_support::read_of(&observed, query(IgnoredEntries::Only)),
4373 &provenance,
4374 )
4375 .expect("report"),
4376 Format::Text,
4377 false,
4378 );
4379 assert!(only.contains(" 128 B 1 file, 1 directory\n"), "{only}");
4380 assert!(!only.contains("ignored"), "every row is ignored, so none repeats it: {only}");
4381
4382 let json = render(
4383 &report(
4384 &observed,
4385 &crate::test_support::read_of(&observed, query(IgnoredEntries::Include)),
4386 &provenance,
4387 )
4388 .expect("report"),
4389 Format::Json,
4390 false,
4391 );
4392 assert!(is_valid_json(&json), "{json}");
4393 let compact = compact_json(&json);
4394 for expected in [
4395 "\"summary\": {\"files\": 2, \"dirs\": 2, \"bytes\": 164, \"allocated\": 1024, \
4396 \"ignored\": {\"files\": 1, \"dirs\": 1, \"bytes\": 128, \"allocated\": 512}, ",
4397 "\"name\": \"src\", \"path\": \"src\", \"kind\": \"dir\", \"entry_ignored\": false, \"bytes\": 36, \
4398 \"allocated\": 512, \"files\": 1, \"dirs\": 0, \"ignored\": {\"files\": 0, \
4399 \"dirs\": 0, \"bytes\": 0, \"allocated\": 0}, ",
4400 "{\"extension\": \".gz\", \"files\": 1, \"bytes\": 128, \"allocated\": 512, \
4401 \"ignored\": {\"files\": 1, \"bytes\": 128, \"allocated\": 512}}",
4402 "\"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}",
4403 ] {
4404 assert!(compact.contains(&compact_json(expected)), "missing {expected}\nin {json}");
4405 }
4406 let yaml = render(
4407 &report(
4408 &observed,
4409 &crate::test_support::read_of(&observed, query(IgnoredEntries::Include)),
4410 &provenance,
4411 )
4412 .expect("report"),
4413 Format::Yaml,
4414 false,
4415 );
4416 assert!(
4417 yaml.contains(
4418 " allocated: 1024\n ignored: {files: 1, dirs: 1, bytes: 128, \
4419 allocated: 512}\n newest_mtime_ns: 20\n"
4420 ),
4421 "{yaml}"
4422 );
4423 assert!(yaml.contains(" ignored: true\n"), "{yaml}");
4424
4425 let blind = build(crate::test_support::not_observing_controls());
4426 let blind_report = report(
4427 &blind,
4428 &crate::test_support::read_of(&blind, query(IgnoredEntries::Include)),
4429 &provenance,
4430 )
4431 .expect("report");
4432 assert!(!render(&blind_report, Format::Text, false).contains("ignored"));
4433 let json = render(&blind_report, Format::Json, false);
4434 assert!(!json.contains("\"ignored\": {"), "never a zero share for an unread rule: {json}");
4435 assert!(json.contains("\"ignored\": null"), "{json}");
4436 assert!(render(&blind_report, Format::Yaml, false).contains("ignored: null\n"));
4437 }
4438
4439 #[test]
4440 fn every_report_uses_one_schema_and_states_nullable_analysis() {
4441 let metadata = render(&fixture(&[ViewSpec::Tree]), Format::Json, false);
4442 assert!(metadata.contains("\"schema\": \"fdu.report/10\""));
4443 assert!(metadata.contains("\"analysis\": null"));
4444
4445 let metrics = render(&fixture(&[ViewSpec::Types]), Format::Json, false);
4446 assert!(metrics.contains("\"schema\": \"fdu.report/10\""));
4447 assert!(metrics.contains("\"analysis\": null"));
4448 assert!(metrics.contains("\"share\": {\"numerator\":"));
4449 }
4450
4451 #[cfg(feature = "watch")]
4461 #[test]
4462 fn a_stream_record_is_pinned_field_by_field() {
4463 use crate::{Change, ChangeKind};
4464
4465 assert_eq!(STREAM_SCHEMA, "fdu.stream/2");
4466
4467 let upsert = Change {
4468 path: ["src", "main.rs"].iter().collect(),
4469 kind: ChangeKind::Upsert,
4470 entry_kind: Some(EntryKind::File),
4471 bytes: Some(2_048),
4472 allocated: Some(4_096),
4473 mtime_ns: Some(1_700_000_000_000_000_000),
4474 ignored: Some(false),
4475 clock: 7,
4476 };
4477 let path = upsert.path.to_string_lossy().replace('\\', "\\\\");
4480 assert_eq!(
4481 render_change(&upsert, Format::Json),
4482 format!(
4483 "{{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"upsert\", \
4484 \"path\": \"{path}\", \"clock\": 7, \"kind\": \"file\", \"bytes\": 2048, \
4485 \"allocated\": 4096, \"mtime_ns\": 1700000000000000000, \"ignored\": false}}"
4486 )
4487 );
4488
4489 let unclassified = Change { ignored: None, ..upsert.clone() };
4492 assert_eq!(
4493 render_change(&unclassified, Format::Json),
4494 format!(
4495 "{{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"upsert\", \
4496 \"path\": \"{path}\", \"clock\": 7, \"kind\": \"file\", \"bytes\": 2048, \
4497 \"allocated\": 4096, \"mtime_ns\": 1700000000000000000}}"
4498 )
4499 );
4500
4501 let removed = Change {
4505 path: PathBuf::from("gone.txt"),
4506 kind: ChangeKind::Remove,
4507 entry_kind: None,
4508 bytes: None,
4509 allocated: None,
4510 mtime_ns: None,
4511 ignored: None,
4512 clock: 8,
4513 };
4514 assert_eq!(
4515 render_change(&removed, Format::Json),
4516 "{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"remove\", \
4517 \"path\": \"gone.txt\", \"clock\": 8}"
4518 );
4519
4520 let reclassified =
4523 Change { path: PathBuf::from("debug.log"), ignored: Some(true), ..removed.clone() };
4524 assert_eq!(
4525 render_change(&reclassified, Format::Json),
4526 "{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"remove\", \
4527 \"path\": \"debug.log\", \"clock\": 8, \"ignored\": true}"
4528 );
4529
4530 let invalidated = Change {
4533 path: PathBuf::from("subtree"),
4534 kind: ChangeKind::Invalidate,
4535 entry_kind: None,
4536 bytes: None,
4537 allocated: None,
4538 mtime_ns: None,
4539 ignored: None,
4540 clock: 9,
4541 };
4542 assert_eq!(
4543 render_change(&invalidated, Format::Json),
4544 "{\"schema\": \"fdu.stream/2\", \"record\": \"change\", \"op\": \"invalidate\", \
4545 \"path\": \"subtree\", \"clock\": 9}"
4546 );
4547
4548 assert_eq!(render_change(&removed, Format::Text), "gone.txt\tremove");
4550 }
4551
4552 #[test]
4553 fn a_files_view_prints_one_path_per_line_and_nothing_else() {
4554 let text = render(&fixture(&[ViewSpec::Files]), Format::Text, false);
4558 for line in text.lines() {
4559 assert!(!line.contains(' '), "text files output must be bare paths, got {line:?}");
4560 }
4561 let expected: PathBuf = ["src", "main.rs"].iter().collect();
4562 let expected = expected.display().to_string();
4563 assert!(text.lines().any(|line| line == expected), "{text}");
4564 }
4565
4566 #[test]
4567 fn several_views_are_labelled_and_a_lone_view_is_left_bare() {
4568 let text = render(
4571 &fixture(&[ViewSpec::Tree, ViewSpec::Types, ViewSpec::Summary]),
4572 Format::Text,
4573 false,
4574 );
4575 let headers: Vec<&str> = text.lines().filter(|line| is_view_header_line(line)).collect();
4576 assert_eq!(headers, ["TREE", "TYPES", "SUMMARY"], "{text}");
4577
4578 let lines: Vec<&str> = text.lines().collect();
4581 for (index, line) in lines.iter().enumerate() {
4582 if headers.contains(line) {
4583 assert!(
4584 lines.get(index + 1).is_some_and(|next| !next.is_empty()),
4585 "header {line} must sit directly above its rows:\n{text}"
4586 );
4587 if index > 0 {
4588 assert!(
4589 lines[index - 1].is_empty(),
4590 "a blank line must precede header {line}:\n{text}"
4591 );
4592 }
4593 }
4594 }
4595
4596 for view in [ViewSpec::Tree, ViewSpec::Types, ViewSpec::Summary] {
4598 let lone = render(&fixture(&[view]), Format::Text, false);
4599 assert!(
4600 !lone.lines().any(is_view_header_line),
4601 "{view:?} alone must not be labelled:\n{lone}"
4602 );
4603 }
4604 }
4605
4606 #[test]
4607 fn view_headers_are_colorized_only_when_color_is_on() {
4608 let views = [ViewSpec::Tree, ViewSpec::Summary];
4609 let plain = render(&fixture(&views), Format::Text, false);
4610 assert!(plain.starts_with("TREE\n"), "{plain}");
4611 assert!(!plain.contains('\u{1b}'), "uncolored text carries no escapes: {plain:?}");
4612
4613 let colored = render(&fixture(&views), Format::Text, true);
4614 assert!(colored.contains(&paint("TREE", STYLE_HEADING, true)), "{colored:?}");
4615 assert!(colored.contains(&paint("SUMMARY", STYLE_HEADING, true)), "{colored:?}");
4616 }
4617
4618 #[test]
4619 fn human_rows_style_only_their_semantic_spans() {
4620 let ignored = IgnoredTally { files: 1, dirs: 0, bytes: 43, allocated: 43 };
4621 let mut tree = fixture(&[ViewSpec::Tree]);
4622 let Section::Tree { root: Some(root), .. } = &mut tree.sections[0] else { panic!("tree") };
4623 root.name = "a(b)\n界".into();
4624 root.files = 3_508;
4625 root.ignored = Some(ignored);
4626 let plain = render(&tree, Format::Text, false);
4627 let colored = render(&tree, Format::Text, true);
4628 assert!(
4629 colored.contains(&format!(
4630 "{} 3,508 files {}",
4631 human_name("a(b)\n界", EntryKind::Dir, None, true),
4632 detail("(43 B gitignored)", true)
4633 )),
4634 "{colored:?}"
4635 );
4636 assert_eq!(strip_ansi(&colored).replace('·', "░").replace('▒', "█"), plain);
4637 assert!(!colored.contains("a(b)\n界"));
4638
4639 let mut summary = fixture(&[ViewSpec::Summary]);
4640 let Section::Summary(row) = &mut summary.sections[0] else { panic!("summary") };
4641 row.files = 3_508;
4642 row.ignored = Some(ignored);
4643 let colored = render(&summary, Format::Text, true);
4644 assert!(
4645 colored.contains(&format!(
4646 "3,508 files, 1 directory {}",
4647 detail("(43 B gitignored)", true)
4648 )),
4649 "{colored:?}"
4650 );
4651 assert_eq!(strip_ansi(&colored), render(&summary, Format::Text, false));
4652
4653 let mut types = fixture(&[ViewSpec::Extensions]);
4654 let Section::Extensions { rows, .. } = &mut types.sections[0] else { panic!("extensions") };
4655 rows[0].extension = ".(txt)".into();
4656 rows[0].files = 3_508;
4657 rows[0].ignored = Some(ignored);
4658 let colored = render(&types, Format::Text, true);
4659 assert!(colored.contains(&paint(".(txt)", STYLE_CATEGORY, true)), "{colored:?}");
4660 assert!(
4661 colored.contains(&format!("3,508 files {}", detail("(43 B gitignored)", true))),
4662 "{colored:?}"
4663 );
4664 assert_eq!(strip_ansi(&colored), render(&types, Format::Text, false));
4665 }
4666
4667 #[test]
4668 fn metric_breakdowns_and_ranked_paths_keep_span_boundaries() {
4669 let mut metrics = fixture(&[ViewSpec::Types]);
4670 let Section::Metrics { summary, .. } = &mut metrics.sections[0] else { panic!("metrics") };
4671 let row = &mut summary.rows[0];
4672 row.files = 1_234;
4673 row.metrics.physical_lines = Some(477_298);
4674 row.metrics.nonblank_lines = Some(439_949);
4675 row.metrics.blank_lines = Some(37_349);
4676 let colored = render(&metrics, Format::Text, true);
4677 assert!(
4678 colored.contains(&format!(
4679 "1,234 files, 477,298 lines {}",
4680 detail("(439,949 nonblank, 37,349 blank)", true)
4681 )),
4682 "{colored:?}"
4683 );
4684 assert_eq!(strip_ansi(&colored), render(&metrics, Format::Text, false));
4685
4686 let mut largest = fixture(&[ViewSpec::Largest]);
4687 let Section::Files { rows, .. } = &mut largest.sections[0] else { panic!("files") };
4688 rows[0].path = PathBuf::from("a(b)\n界.rs");
4689 let colored = render(&largest, Format::Text, true);
4690 assert!(colored.contains(&paint("a(b)\\n界.rs", STYLE_NAME, true)), "{colored:?}");
4691 assert_eq!(strip_ansi(&colored), render(&largest, Format::Text, false));
4692 for format in [Format::Json, Format::Jsonl, Format::Yaml] {
4693 assert!(!render(&largest, format, true).contains('\u{1b}'));
4694 }
4695 }
4696
4697 #[test]
4698 fn code_population_details_keep_known_and_unknown_contributions_visible() {
4699 use crate::query::{CodeLanguageRow, MetricShare};
4700 let tally = |lines| CodeTally {
4701 source_files: 1,
4702 analyzed_files: 1,
4703 metrics: crate::content::CodeMetrics { code_lines: lines, ..Default::default() },
4704 ..Default::default()
4705 };
4706 let mut overview = CodeOverview {
4707 population: IgnoredEntries::Include,
4708 selected: CodeTally { source_files: 3, analyzed_files: 3, ..tally(110) },
4709 non_ignored: Some(tally(80)),
4710 ignored: Some(tally(20)),
4711 unknown: tally(10),
4712 unclassified_files: 0,
4713 analyzed_languages: 1,
4714 total_languages: 1,
4715 share_omitted: 0,
4716 languages: vec![CodeLanguageRow {
4717 language: "rust".into(),
4718 selected: CodeTally { source_files: 3, analyzed_files: 3, ..tally(110) },
4719 non_ignored: Some(tally(80)),
4720 ignored: Some(tally(20)),
4721 unknown: tally(10),
4722 share: MetricShare { numerator: 110, denominator: 110 },
4723 }],
4724 share_metric: ShareMetric::CodeLines,
4725 };
4726 let mut report = fixture(&[ViewSpec::Summary]);
4727 report.sections = vec![Section::Code(Box::new(overview.clone()))];
4728 let colored = render(&report, Format::Text, true);
4729 assert!(colored.contains(&paint("TOTAL ", AnsiStyle::new().bold(), true)), "{colored:?}");
4730 let colored_total = colored.lines().find(|line| line.contains("TOTAL")).expect("total row");
4731 assert_eq!(colored_total.matches("\x1b[1m").count(), 6, "all primary TOTAL cells are bold");
4732 assert!(colored.contains(&detail("(20 gitignored, 10 unknown)", true)), "{colored:?}");
4733 assert!(!colored.contains("non-gitignored"), "the complement is never repeated");
4734 let plain = strip_ansi(&colored);
4735 assert!(
4736 plain.lines().next().expect("header").contains("Analyzed files Language"),
4737 "{plain}"
4738 );
4739 let total = plain.lines().find(|line| line.contains("TOTAL")).expect("total row");
4740 assert_eq!(
4741 total.split_whitespace().take(6).collect::<Vec<_>>(),
4742 ["110", "100.0%", "0", "0", "3/3", "TOTAL"]
4743 );
4744 assert_eq!(
4745 plain.matches(" 110").count(),
4746 2,
4747 "row and TOTAL retain the same measured total"
4748 );
4749 assert_eq!(strip_ansi(&colored), render(&report, Format::Text, false));
4750 for format in [Format::Json, Format::Jsonl, Format::Yaml] {
4751 assert!(!render(&report, format, true).contains('\u{1b}'));
4752 }
4753 overview.population = IgnoredEntries::Only;
4754 overview.non_ignored = None;
4755 overview.ignored = None;
4756 overview.unknown = CodeTally::default();
4757 overview.languages[0].non_ignored = None;
4758 overview.languages[0].ignored = None;
4759 overview.languages[0].unknown = CodeTally::default();
4760 report.sections = vec![Section::Code(Box::new(overview))];
4761 let text = render(&report, Format::Text, false);
4762 assert!(!text.contains("gitignored") && !text.contains(" unknown"), "{text}");
4763 }
4764
4765 #[test]
4766 fn code_table_keeps_global_total_when_rows_are_bounded_and_marks_unmeasured_values() {
4767 use crate::query::{CodeLanguageRow, MetricShare};
4768 let mut overview = CodeOverview {
4769 population: IgnoredEntries::Include,
4770 selected: CodeTally {
4771 source_files: 2,
4772 analyzed_files: 2,
4773 metrics: crate::content::CodeMetrics { code_lines: 12, ..Default::default() },
4774 ..Default::default()
4775 },
4776 non_ignored: None,
4777 ignored: None,
4778 unknown: CodeTally::default(),
4779 unclassified_files: 0,
4780 analyzed_languages: 1,
4781 total_languages: 1,
4782 share_omitted: 0,
4783 languages: Vec::new(),
4784 share_metric: ShareMetric::CodeLines,
4785 };
4786 let mut report = fixture(&[ViewSpec::Summary]);
4787 report.sections = vec![Section::Code(Box::new(overview.clone()))];
4788 let bounded = render(&report, Format::Text, false);
4789 let total = bounded.lines().find(|line| line.contains("TOTAL")).expect("total row");
4790 assert_eq!(
4791 total.split_whitespace().take(6).collect::<Vec<_>>(),
4792 ["12", "100.0%", "0", "0", "2/2", "TOTAL"]
4793 );
4794 assert!(!bounded.contains("Rust"), "{bounded}");
4795
4796 overview.selected.analyzed_files = 0;
4797 overview.selected.metrics = crate::content::CodeMetrics::default();
4798 overview.selected.coverage.insert(CoverageReason::Unsupported, 2);
4799 overview.analyzed_languages = 0;
4800 overview.non_ignored = Some(CodeTally::default());
4801 overview.ignored = Some(CodeTally::default());
4802 overview.languages = vec![CodeLanguageRow {
4803 language: "rust".into(),
4804 selected: CodeTally { source_files: 2, ..Default::default() },
4805 non_ignored: Some(CodeTally::default()),
4806 ignored: Some(CodeTally::default()),
4807 unknown: CodeTally::default(),
4808 share: MetricShare { numerator: 0, denominator: 0 },
4809 }];
4810 report.sections = vec![Section::Code(Box::new(overview))];
4811 let unmeasured = render(&report, Format::Text, false);
4812 let language = unmeasured.lines().find(|line| line.contains("Rust")).expect("language row");
4813 let total = unmeasured.lines().find(|line| line.contains("TOTAL")).expect("total row");
4814 assert_eq!(
4815 language.split_whitespace().take(6).collect::<Vec<_>>(),
4816 ["—", "—", "—", "—", "0/2", "Rust"]
4817 );
4818 assert_eq!(
4819 total.split_whitespace().take(6).collect::<Vec<_>>(),
4820 ["—", "—", "—", "—", "0/2", "TOTAL"]
4821 );
4822 assert!(!unmeasured.contains("gitignored"), "{unmeasured}");
4823 assert!(unmeasured.contains("2 unsupported"), "{unmeasured}");
4824 assert!(unmeasured.lines().all(|line| line.trim_end() == line), "{unmeasured:?}");
4825 }
4826
4827 #[test]
4828 fn percentage_and_unicode_width_keep_small_values_visible() {
4829 assert_eq!(human_percentage(1, 10_000, 1), "<0.1%");
4830 assert_eq!(human_percentage(1, 10_000, 0), "<1%");
4831 assert_eq!(human_percentage(0, 10_000, 1), "0.0%");
4832 assert_eq!(human_percentage(0, 0, 1), "—");
4833 assert_eq!(label_cell("界", 4, STYLE_CATEGORY, false), "界 ");
4834 assert_eq!(label_cell("e\u{301}", 4, STYLE_CATEGORY, false), "e\u{301} ");
4835 }
4836
4837 #[test]
4838 fn no_machine_format_gains_a_text_header() {
4839 let views = [ViewSpec::Tree, ViewSpec::Types, ViewSpec::Files, ViewSpec::Summary];
4842 for format in [Format::Json, Format::Jsonl, Format::Yaml] {
4843 let rendered = render(&fixture(&views), format, false);
4844 for header in ["TREE", "TYPES", "FILES", "SUMMARY"] {
4845 assert!(!rendered.contains(header), "{format:?} leaked {header}:\n{rendered}");
4846 }
4847 }
4848 }
4849
4850 #[test]
4851 fn every_view_has_a_header_that_matches_its_wire_label() {
4852 for view in [
4856 ViewSpec::Tree,
4857 ViewSpec::Extensions,
4858 ViewSpec::Types,
4859 ViewSpec::Families,
4860 ViewSpec::Languages,
4861 ViewSpec::Documents,
4862 ViewSpec::Files,
4863 ViewSpec::Summary,
4864 ] {
4865 let header = view_header(view);
4866 assert_eq!(header, view.label().to_uppercase(), "{view:?}");
4867 assert!(
4868 !header.is_empty() && header.chars().all(|c| c.is_ascii_uppercase()),
4869 "{view:?}"
4870 );
4871 }
4872 }
4873
4874 #[test]
4875 fn yaml_quotes_only_what_would_be_ambiguous() {
4876 assert_eq!(yaml_scalar("cold_scan"), "cold_scan");
4877 assert_eq!(yaml_scalar("src/main.rs"), "src/main.rs");
4878 assert_eq!(yaml_scalar("true"), "\"true\"");
4880 assert_eq!(yaml_scalar("null"), "\"null\"");
4881 assert_eq!(yaml_scalar("12345"), "\"12345\"");
4882 assert_eq!(yaml_scalar(""), "\"\"");
4883 assert_eq!(yaml_scalar("has space"), "\"has space\"");
4884 assert_eq!(yaml_scalar("-leading-dash"), "\"-leading-dash\"");
4885 }
4886
4887 #[test]
4888 fn json_strings_escape_control_characters_and_quotes() {
4889 assert_eq!(quote("a\"b"), "\"a\\\"b\"");
4890 assert_eq!(quote("a\\b"), "\"a\\\\b\"");
4891 assert_eq!(quote("a\nb"), "\"a\\nb\"");
4892 assert_eq!(quote("a\u{1}b"), "\"a\\u0001b\"");
4893 }
4894
4895 #[test]
4896 fn format_values_parse_and_reject_by_name() {
4897 assert_eq!(Format::parse("json"), Some(Format::Json));
4898 assert_eq!(Format::parse(" YAML "), Some(Format::Yaml));
4899 assert_eq!(Format::parse("xml"), None);
4900 assert_eq!(Format::ALL.len(), 7);
4901 }
4902
4903 #[test]
4904 fn human_bytes_reads_at_scale() {
4905 assert_eq!(human_bytes(0), "0 B");
4906 assert_eq!(human_bytes(512), "512 B");
4907 assert_eq!(human_bytes(999), "999 B");
4908 assert_eq!(human_bytes(1000), "1,000 B");
4909 assert_eq!(human_bytes(1024), "1.0 KiB");
4910 assert_eq!(human_bytes(1024 * 1024 * 20), "20 MiB");
4911 }
4912
4913 #[test]
4914 fn human_counts_share_one_full_width_grouping_policy() {
4915 assert_eq!(human_count(999), "999");
4916 assert_eq!(human_count(1000), "1,000");
4917 assert_eq!(
4918 human_count_u128(u128::MAX),
4919 "340,282,366,920,938,463,463,374,607,431,768,211,455"
4920 );
4921 assert_eq!(human_age(Some(1000 * 86400 * 1_000_000_000)), "1,000d");
4922 }
4923
4924 #[test]
4925 fn bars_are_fixed_at_ten_cells_and_saturate() {
4926 assert_eq!(usage_bar(0, 100, Some(0), false, 10), "░░░░░░░░░░");
4927 assert_eq!(usage_bar(50, 100, Some(0), false, 10), "█████░░░░░");
4928 assert_eq!(usage_bar(200, 100, Some(0), false, 10), "██████████");
4929 assert!((ratio(5, 0) - 0.0).abs() < f64::EPSILON);
4930 assert_eq!(bar_cells(u64::MAX / 2, u64::MAX, 1), 0);
4931 assert_eq!(bar_cells(u64::MAX / 2 + 1, u64::MAX, 1), 1);
4932 }
4933
4934 #[test]
4935 fn human_styles_respect_exact_thresholds_and_directory_identity() {
4936 assert_eq!(styled_bytes(0, 0, true, false), "\x1b[90m0 B\x1b[0m");
4937 assert_eq!(styled_bytes(0, 0, false, false), "0 B");
4938 let gib = 1 << 30;
4939 assert_eq!(styled_bytes(gib - 1, 10, true, false), format!("{:>10}", human_bytes(gib - 1)));
4940 assert_eq!(
4941 styled_bytes(gib, 10, true, false),
4942 paint(" 1.0 GiB", AnsiStyle::new().bold(), true)
4943 );
4944 assert_eq!(styled_bytes(gib, 0, true, true), paint("1.0 GiB", STYLE_DETAIL.bold(), true));
4945 assert_eq!(styled_bytes(gib, 0, false, true), "1.0 GiB");
4946 assert_eq!(percentage_cell(99, 10_000, 0, 5, true), detail(" <1%", true));
4947 assert_eq!(percentage_cell(100, 10_000, 0, 5, true), " 1%");
4948 assert_eq!(human_percentage(u64::MAX / 100, u64::MAX, 0), "<1%");
4949 assert_eq!(human_percentage(u64::MAX / 100 + 1, u64::MAX, 0), "1%");
4950 assert_eq!(human_percentage(u64::MAX / 1_000, u64::MAX, 1), "<0.1%");
4951 for name in [".", ".."] {
4952 assert_eq!(human_name(name, EntryKind::Dir, None, false), name);
4953 }
4954 assert_eq!(
4955 human_name("build", EntryKind::Dir, None, true),
4956 format!("{}{}", paint("build", STYLE_NAME, true), detail("/", true))
4957 );
4958 assert_eq!(human_name("build", EntryKind::File, None, false), "build");
4959 assert_eq!(human_name("build", EntryKind::Dir, None, false), "build/");
4960 }
4961
4962 #[test]
4963 fn only_own_ignored_directories_lose_bold_name_styling() {
4964 for ignored in [None, Some(false), Some(true)] {
4965 let style = if ignored == Some(true) { STYLE_IGNORED_NAME } else { STYLE_NAME };
4966 assert_eq!(
4967 human_name("node_modules", EntryKind::Dir, ignored, true),
4968 format!("{}{}", paint("node_modules", style, true), detail("/", true))
4969 );
4970 assert_eq!(
4971 human_name("file.rs", EntryKind::File, ignored, true),
4972 paint("file.rs", STYLE_NAME, true)
4973 );
4974 assert_eq!(human_name("node_modules", EntryKind::Dir, ignored, false), "node_modules/");
4975 }
4976 }
4977
4978 #[test]
4979 fn colored_bars_partition_selected_usage_and_keep_ten_cells() {
4980 let split = usage_bar(60, 100, Some(20), true, 10);
4981 assert_eq!(
4982 split,
4983 format!(
4984 "{}{}{}",
4985 paint("████", STYLE_BAR, true),
4986 paint("▓▓", STYLE_BAR, true),
4987 paint("░░░░", STYLE_BAR.dimmed(), true)
4988 )
4989 );
4990 assert_eq!(strip_ansi(&split).chars().count(), 10);
4991 assert_eq!(usage_bar(60, 100, Some(20), false, 10), "██████░░░░");
4992 assert_eq!(strip_ansi(&usage_bar(60, 60, Some(60), true, 10)), "▓▓▓▓▓▓▓▓▓▓");
4993 assert_eq!(
4994 usage_bar(60, 60, Some(60), true, 10),
4995 format!(
4996 "{}{}{}",
4997 paint("", STYLE_BAR, true),
4998 paint("▓▓▓▓▓▓▓▓▓▓", STYLE_BAR, true),
4999 paint("", STYLE_BAR.dimmed(), true)
5000 )
5001 );
5002 assert_eq!(strip_ansi(&usage_bar(0, 0, None, true, 10)), "░░░░░░░░░░");
5003 let mostly_ignored = usage_bar(130, 2_120, Some(96), true, 10);
5004 assert_eq!(strip_ansi(&mostly_ignored), "▓░░░░░░░░░");
5005 assert!(mostly_ignored.contains("\x1b[32m▓\x1b[0m"));
5006 assert_eq!(strip_ansi(&usage_bar(130, 2_120, Some(96), true, 20)), "▓░░░░░░░░░░░░░░░░░░░");
5007 assert_eq!(strip_ansi(&usage_bar(130, 2_120, Some(96), true, 100)).matches('▓').count(), 4);
5008 }
5009
5010 #[test]
5011 fn render_options_resize_or_remove_tree_bars_without_changing_data() {
5012 let report = fixture_for(&Query {
5013 views: vec![ViewSpec::Tree],
5014 selection: Selection {
5015 size: SizeMetric::Apparent,
5016 depth: Some(Bound::Limit(0)),
5017 ..Selection::default()
5018 },
5019 ..Query::default()
5020 });
5021 for width in [0, 10, 20] {
5022 let options = RenderOptions { color: false, bar_size: width };
5023 let text = render_with_options(&report, Format::Text, options).expect("tree");
5024 let lines = text.lines().collect::<Vec<_>>();
5025 assert_eq!(lines.len(), 2, "{width}: {text:?}");
5026 for line in &lines {
5027 if width == 0 {
5028 assert!(line.starts_with(" 100%"), "{line:?}");
5029 } else {
5030 assert!(line.starts_with(&"█".repeat(width)), "{line:?}");
5031 assert!(line["█".repeat(width).len()..].starts_with(" 100%"));
5032 }
5033 }
5034 assert!(lines[0].ends_with("120 B . 2 files"), "{text:?}");
5035 assert!(lines[1].ends_with("120 B … and 2 more files"), "{text:?}");
5036 let mut streamed = Vec::new();
5037 write_with_options(&report, Format::Text, options, &mut streamed).expect("stream");
5038 assert_eq!(streamed, text.as_bytes());
5039 }
5040 assert_eq!(
5041 render_with_options(&report, Format::Json, RenderOptions { color: true, bar_size: 20 })
5042 .expect("machine"),
5043 render(&report, Format::Json, false)
5044 );
5045 let excessive = RenderOptions { color: false, bar_size: MAX_BAR_SIZE + 1 };
5046 assert!(
5047 render_with_options(&report, Format::Text, excessive)
5048 .expect_err("tree width must be bounded")
5049 .to_string()
5050 .contains("bar_size")
5051 );
5052 let mut output = Vec::new();
5053 assert_eq!(
5054 write_with_options(&report, Format::Text, excessive, &mut output)
5055 .expect_err("streaming tree width must be bounded")
5056 .kind(),
5057 io::ErrorKind::InvalidInput
5058 );
5059 assert!(output.is_empty());
5060 let summary = fixture(&[ViewSpec::Summary]);
5061 assert_eq!(
5062 render_with_options(&summary, Format::Text, excessive).expect("no tree bar"),
5063 render(&summary, Format::Text, false)
5064 );
5065 }
5066
5067 const DEEP_RENDER_CHILD_ENV: &str = "FDU_DEEP_RENDER_CHILD";
5068 const DEEP_RENDER_DEPTH: usize = 1_024;
5069 const DEEP_REPORT_STACK_BYTES: usize = 128 * 1_024;
5070 const DEEP_RENDER_STACK_BYTES: usize = 64 * 1_024;
5071
5072 #[test]
5079 fn deep_rendering_is_stack_safe() {
5080 if std::env::var_os(DEEP_RENDER_CHILD_ENV).is_some() {
5081 run_deep_render_child();
5082 return;
5083 }
5084
5085 let started = Instant::now();
5086 let mut child = Command::new(std::env::current_exe().expect("current test executable"))
5087 .args(["--exact", DEEP_RENDER_TEST_PATH, "--nocapture"])
5088 .env(DEEP_RENDER_CHILD_ENV, "1")
5089 .stdin(Stdio::null())
5090 .stdout(Stdio::piped())
5091 .stderr(Stdio::piped())
5092 .spawn()
5093 .expect("spawn deep-render child");
5094 let stdout = drain_pipe(child.stdout.take().expect("piped stdout"));
5095 let stderr = drain_pipe(child.stderr.take().expect("piped stderr"));
5096 let status = loop {
5097 if let Some(status) = child.try_wait().expect("poll deep-render child") {
5098 break Some(status);
5099 }
5100 if started.elapsed() >= DEEP_RENDER_CHILD_TIMEOUT {
5101 break None;
5102 }
5103 std::thread::sleep(Duration::from_millis(50));
5104 };
5105 let Some(status) = status else {
5106 let _ = child.kill();
5111 let reaped = child.wait().expect("reap the killed deep-render child");
5112 panic!(
5113 "deep-render child still running after {DEEP_RENDER_CHILD_TIMEOUT:?}; killed at \
5114 {:?} ({reaped}). The last `deep-render phase:` line is where it stalled.\n\
5115 stdout:\n{}\nstderr:\n{}",
5116 started.elapsed(),
5117 stdout.join().expect("stdout reader"),
5118 stderr.join().expect("stderr reader")
5119 );
5120 };
5121 let stdout = stdout.join().expect("stdout reader");
5122 let stderr = stderr.join().expect("stderr reader");
5123 assert!(
5124 status.success(),
5125 "deep renderer failed in child process\nstdout:\n{stdout}\nstderr:\n{stderr}"
5126 );
5127
5128 assert!(
5133 stdout.contains("1 passed"),
5134 "the child must actually run the deep render, not filter it away\nstdout:\n{stdout}"
5135 );
5136 }
5137
5138 const DEEP_RENDER_TEST_PATH: &str = "report_format::tests::deep_rendering_is_stack_safe";
5142
5143 const DEEP_RENDER_CHILD_TIMEOUT: Duration = Duration::from_secs(300);
5152
5153 fn drain_pipe(mut pipe: impl io::Read + Send + 'static) -> std::thread::JoinHandle<String> {
5159 std::thread::spawn(move || {
5160 let mut bytes = Vec::new();
5161 let _ = pipe.read_to_end(&mut bytes);
5163 String::from_utf8_lossy(&bytes).into_owned()
5164 })
5165 }
5166
5167 fn run_deep_render_child() {
5168 eprintln!("deep-render phase: fixture");
5174 let mut index = crate::Index::new("/fixture");
5175 let mut path = PathBuf::new();
5176 for depth in 0..DEEP_RENDER_DEPTH {
5177 path.push("d");
5178 index.apply_ok(&crate::Observation::new(vec![crate::Op::Upsert {
5179 path: path.clone(),
5180 kind: EntryKind::Dir,
5181 attrs: crate::Attrs {
5182 mtime_ns: i64::try_from(depth).expect("fixture depth fits i64"),
5183 ..Default::default()
5184 },
5185 }]));
5186 }
5187 index.set_initial_freshness(false);
5188
5189 let report = std::thread::Builder::new()
5190 .name("deep-report".to_string())
5191 .stack_size(DEEP_REPORT_STACK_BYTES)
5192 .spawn(move || {
5193 let query = Query {
5194 selection: Selection {
5195 depth: Some(Bound::All),
5196 breadth: Some(Bound::All),
5197 limit: Some(Bound::All),
5198 min_share: Some(ShareThreshold::parse("0%").expect("zero share is valid")),
5199 ..Selection::default()
5200 },
5201 views: vec![ViewSpec::Tree],
5202 ..Query::default()
5203 };
5204 let provenance = Provenance {
5205 scan_started_at: None,
5206 generated_at: SystemTime::UNIX_EPOCH,
5207 source: ReportSource::ColdScan,
5208 complete: true,
5209 errors: Vec::new(),
5210 };
5211 eprintln!("deep-render phase: request");
5212 let request = crate::test_support::read_of(&index, query);
5213 eprintln!("deep-render phase: report");
5214 let report = report(&index, &request, &provenance).expect("report");
5215 eprintln!("deep-render phase: verify tree");
5216 let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
5217 panic!("expected a tree section with a root")
5218 };
5219 assert!(omissions.is_empty(), "the root must not be omitted");
5220 let mut nodes = 0;
5221 let mut pending = vec![root.as_ref()];
5222 while let Some(node) = pending.pop() {
5223 nodes += 1;
5224 assert!(node.omissions.is_empty(), "no branch may be omitted: {:?}", node.path);
5225 pending.extend(node.children.iter());
5226 }
5227 assert_eq!(nodes, DEEP_RENDER_DEPTH + 1, "the test must reach every directory");
5228 report
5229 })
5230 .expect("spawn deep-report thread")
5231 .join()
5232 .expect("deep-report thread");
5233
5234 std::thread::Builder::new()
5235 .name("deep-render".to_string())
5236 .stack_size(DEEP_RENDER_STACK_BYTES)
5237 .spawn(move || {
5238 for format in [Format::Text, Format::Json, Format::Jsonl, Format::Yaml] {
5239 eprintln!("deep-render phase: render {format:?}");
5240 let rendered = render(&report, format, false);
5241 assert!(!rendered.is_empty(), "{format:?} rendered nothing for a deep tree");
5242 if format != Format::Text {
5243 eprintln!("deep-render phase: stream {format:?}");
5244 let mut streamed = Vec::new();
5245 write(&report, format, false, &mut streamed).expect("stream deep report");
5246 assert_eq!(streamed, rendered.as_bytes(), "{format:?} bytes differ");
5247 }
5248 }
5249 eprintln!("deep-render phase: drop");
5250 drop(report);
5251 eprintln!("deep-render phase: complete");
5252 })
5253 .expect("spawn deep-render thread")
5254 .join()
5255 .expect("deep-render thread");
5256 }
5257
5258 fn assert_json_preserves_raw_identity(
5267 root: PathBuf,
5268 first: &OsStr,
5269 second: &OsStr,
5270 encoding: &str,
5271 root_hex: &str,
5272 first_hex: &str,
5273 second_hex: &str,
5274 ) {
5275 assert_eq!(first.to_string_lossy(), second.to_string_lossy());
5278
5279 let mut index = crate::Index::new(root);
5280 index.apply_ok(&crate::Observation::new(vec![
5281 crate::Op::Upsert {
5282 path: PathBuf::from(first),
5283 kind: EntryKind::File,
5284 attrs: crate::Attrs { size: 1, allocated: 1, ..Default::default() },
5285 },
5286 crate::Op::Upsert {
5287 path: PathBuf::from(second),
5288 kind: EntryKind::File,
5289 attrs: crate::Attrs { size: 1, allocated: 1, ..Default::default() },
5290 },
5291 ]));
5292 index.set_initial_freshness(false);
5293
5294 let query = crate::query::Query {
5298 views: vec![ViewSpec::Files],
5299 selection: Selection { limit: Some(crate::query::Bound::All), ..Selection::default() },
5300 ..crate::query::Query::default()
5301 };
5302 let provenance = Provenance {
5303 scan_started_at: None,
5304 generated_at: std::time::UNIX_EPOCH,
5305 source: ReportSource::ColdScan,
5306 complete: true,
5307 errors: Vec::new(),
5308 };
5309 let files_report =
5310 report(&index, &crate::test_support::read_of(&index, query.clone()), &provenance)
5311 .expect("report");
5312 let rendered = render(&files_report, Format::Json, false);
5313 let mut checked = SchemaCheck::report(JsonSink::pretty(), true, true);
5314 emit_report(&mut checked, &files_report, true);
5315 assert_eq!(checked.finish(), rendered);
5316
5317 let lossy = first.to_string_lossy();
5318 assert_eq!(
5319 rendered.matches(&format!("\"{lossy}\"")).count(),
5320 2,
5321 "both names render the same lossy text: {rendered}"
5322 );
5323 assert!(
5324 rendered.contains(&format!(
5325 "\"root_raw\": {{\"encoding\": \"{encoding}\", \"hex\": \"{root_hex}\"}}"
5326 )),
5327 "{rendered}"
5328 );
5329
5330 for hex in [first_hex, second_hex] {
5336 let row = format!(
5337 "{{\"path\": \"{lossy}\", \"path_raw\": {{\"encoding\": \"{encoding}\", \"hex\": \"{hex}\"}}, \
5338 \"kind\": \"file\", \"bytes\": 1, \"allocated\": 1, \"mtime_ns\": 0, \
5339 \"files\": null, \"dirs\": null, \"complete\": null, \"age_ns\": 0, \"ignored\": false, \"sort_value\": null, \
5340 \"classification\": {{\"file_type\": \"unknown\", \"family\": \"unknown\", \"source\": \"unknown\", \"confidence\": \"heuristic\", \
5341 \"flags\": {{\"generated\": false, \"vendored\": false, \"documentation\": false}}}}}}"
5342 );
5343 assert!(
5344 compact_json(&rendered).contains(&compact_json(&row)),
5345 "a name that is not valid Unicode must carry its raw bytes in a well-formed \
5346 row.\nexpected: {row}\nrendered: {rendered}"
5347 );
5348 }
5349
5350 assert!(
5353 !rendered.contains(", ,") && !rendered.contains(",,"),
5354 "duplicated separator in machine output: {rendered}"
5355 );
5356
5357 let mut dirs = crate::Index::new(PathBuf::from("/tree-fixture"));
5362 dirs.apply_ok(&crate::Observation::new(vec![
5363 crate::Op::Upsert {
5364 path: PathBuf::from(first),
5365 kind: EntryKind::Dir,
5366 attrs: crate::Attrs { size: 0, allocated: 0, ..Default::default() },
5367 },
5368 crate::Op::Upsert {
5369 path: PathBuf::from(first).join("inside.txt"),
5370 kind: EntryKind::File,
5371 attrs: crate::Attrs { size: 1, allocated: 1, ..Default::default() },
5372 },
5373 ]));
5374 dirs.set_initial_freshness(false);
5375 let tree_query = crate::query::Query {
5376 views: vec![ViewSpec::Tree],
5377 selection: Selection {
5378 depth: Some(crate::query::Bound::All),
5379 limit: Some(crate::query::Bound::All),
5380 ..Selection::default()
5381 },
5382 ..crate::query::Query::default()
5383 };
5384 let tree =
5385 report(&dirs, &crate::test_support::read_of(&dirs, tree_query.clone()), &provenance)
5386 .expect("report");
5387 let tree_rendered = render(&tree, Format::Json, false);
5388 assert!(
5389 compact_json(&tree_rendered).contains(&compact_json(&format!(
5390 ", \"path_raw\": {{\"encoding\": \"{encoding}\", \"hex\": \"{first_hex}\"}}, \"kind\":"
5391 ))),
5392 "the tree view must carry raw identity in a well-formed node: {tree_rendered}"
5393 );
5394 assert!(
5395 !tree_rendered.contains(", ,") && !tree_rendered.contains(",,"),
5396 "duplicated separator in tree output: {tree_rendered}"
5397 );
5398 }
5399
5400 #[cfg(unix)]
5401 #[test]
5402 fn json_preserves_distinct_non_unicode_unix_names() {
5403 use std::ffi::OsString;
5404 use std::os::unix::ffi::OsStringExt;
5405
5406 assert_json_preserves_raw_identity(
5407 PathBuf::from(OsString::from_vec(vec![b'/', 0x80])),
5408 &OsString::from_vec(vec![b'n', 0x80]),
5409 &OsString::from_vec(vec![b'n', 0x81]),
5410 "unix-bytes",
5411 "2f80",
5412 "6e80",
5413 "6e81",
5414 );
5415 }
5416
5417 #[cfg(windows)]
5418 #[test]
5419 fn json_preserves_distinct_non_unicode_windows_names() {
5420 use std::ffi::OsString;
5421 use std::os::windows::ffi::OsStringExt;
5422
5423 assert_json_preserves_raw_identity(
5424 PathBuf::from(OsString::from_wide(&[u16::from(b'R'), u16::from(b':'), 0xd800])),
5425 &OsString::from_wide(&[u16::from(b'n'), 0xd800]),
5426 &OsString::from_wide(&[u16::from(b'n'), 0xd801]),
5427 "windows-wtf16le",
5428 "52003a0000d8",
5429 "6e0000d8",
5430 "6e0001d8",
5431 );
5432 }
5433}