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