1mod badge;
2pub mod baseline_advisory_text;
3pub mod ci;
4pub(crate) mod codeclimate;
5mod compact;
6pub(crate) mod config_pattern_text;
7pub mod dupes_grouping;
8pub(crate) mod gate_outcome_text;
9pub mod github;
10pub mod github_annotations;
11pub mod github_summary;
12pub mod grouping;
13pub(crate) mod grouping_note;
14mod human;
15mod json;
16mod markdown;
17pub(crate) mod request_outcome_text;
18pub(crate) mod sarif;
19mod shared;
20pub(crate) mod sink;
21mod status;
22pub(crate) mod suggestions;
23#[cfg(test)]
24pub(crate) mod test_helpers;
25
26use std::path::Path;
27use std::process::ExitCode;
28use std::time::Duration;
29
30use fallow_api::DuplicationGrouping;
31use fallow_config::{OutputFormat, RulesConfig, Severity};
32use fallow_types::duplicates::DuplicationReport;
33use fallow_types::results::AnalysisResults;
34use fallow_types::semantic::SemanticSymbolImpact;
35use fallow_types::trace::{
36 CloneTrace, DependencyTrace, ExportTrace, FileTrace, ImpactClosureTrace, PipelineTimings,
37};
38
39use crate::report::sink::outln;
40
41#[allow(
42 unused_imports,
43 reason = "used by binary crate modules (combined.rs, audit.rs)"
44)]
45pub use fallow_output::strip_root_prefix;
46pub use grouping::OwnershipResolver;
47pub(crate) use human::dupes::MAX_CLONE_GROUPS;
48pub(crate) use human::health::{render_health_score, render_health_trend};
49pub(crate) use status::{
50 HumanStatus, line as human_status_line, semantic_status, type_aware_meta_status,
51};
52
53pub(crate) struct WalkthroughHumanRender {
59 pub(crate) header: Vec<String>,
61 pub(crate) body: Vec<String>,
63 pub(crate) status: String,
65}
66
67#[must_use]
72pub(crate) fn walkthrough_viewed_files(
73 guide: &fallow_output::StandardWalkthroughGuide,
74 viewed: &crate::walkthrough_state::ViewedState,
75) -> Vec<String> {
76 human::walkthrough::viewed_files_for(guide, viewed)
77}
78
79#[must_use]
83pub(crate) fn build_walkthrough_human(
84 guide: &fallow_output::StandardWalkthroughGuide,
85 viewed: &crate::walkthrough_state::ViewedState,
86 show_cleared: bool,
87) -> WalkthroughHumanRender {
88 let input = human::walkthrough::WalkthroughHumanInput {
89 guide,
90 viewed,
91 show_cleared,
92 };
93 WalkthroughHumanRender {
94 header: human::walkthrough::build_focus_header(guide, viewed),
95 body: human::walkthrough::build_walkthrough_human_lines(&input),
96 status: human::walkthrough::build_status_line(guide, viewed),
97 }
98}
99
100pub(crate) struct ReportContext<'a> {
105 pub(crate) package_baselines: &'a [fallow_api::PackageBaselineStatus],
106 pub(crate) root: &'a Path,
107 pub(crate) rules: &'a RulesConfig,
108 pub(crate) workspace_diagnostics: &'a [fallow_config::WorkspaceDiagnostic],
110 pub(crate) elapsed: Duration,
111 pub(crate) quiet: bool,
112 pub(crate) explain: bool,
113 pub(crate) type_aware: Option<&'a fallow_types::envelope::TypeAwareMeta>,
115 pub(crate) type_aware_scope: Option<&'static str>,
118 pub(crate) group_by: Option<OwnershipResolver>,
120 pub(crate) top: Option<usize>,
122 pub(crate) summary: bool,
124 pub(crate) summary_heading: bool,
128 pub(crate) show_explain_tip: bool,
130 pub(crate) baseline_matched: Option<(usize, usize)>,
132 pub(crate) baseline_staleness: Option<fallow_output::BaselineStaleness>,
137 pub(crate) finding_id_query: Option<fallow_output::FindingIdQuery>,
140 pub(crate) gate_outcomes: Option<fallow_output::GateOutcomes>,
143 pub(crate) failed_parse_files: usize,
147 pub(crate) config_fixable: bool,
152 pub(crate) skip_score_and_trend: bool,
157 pub(crate) css_requested: bool,
161 pub(crate) json_style: crate::json_style::JsonStyle,
163 pub(crate) include_fragments: bool,
167}
168
169#[must_use]
171pub(crate) fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
172 path.strip_prefix(root).unwrap_or(path)
173}
174
175#[must_use]
185pub(crate) fn format_display_path(path: &Path, root: &Path) -> String {
186 relative_path(path, root)
187 .display()
188 .to_string()
189 .replace('\\', "/")
190}
191
192#[must_use]
195pub(crate) fn split_dir_filename(path: &str) -> (&str, &str) {
196 path.rfind('/')
197 .map_or(("", path), |pos| (&path[..=pos], &path[pos + 1..]))
198}
199
200#[must_use]
202pub(crate) const fn plural(n: usize) -> &'static str {
203 if n == 1 { "" } else { "s" }
204}
205
206#[expect(
209 clippy::cast_precision_loss,
210 reason = "reported byte counts are well under the f64 precision loss range"
211)]
212#[must_use]
213pub(crate) fn format_bytes(bytes: u64) -> String {
214 const KIB: u64 = 1024;
215 const MIB: u64 = KIB * 1024;
216 const GIB: u64 = MIB * 1024;
217 if bytes >= GIB {
218 format!("{:.1} GiB", bytes as f64 / GIB as f64)
219 } else if bytes >= MIB {
220 format!("{:.1} MiB", bytes as f64 / MIB as f64)
221 } else if bytes >= KIB {
222 format!("{:.0} KiB", bytes as f64 / KIB as f64)
223 } else {
224 format!("{bytes} B")
225 }
226}
227
228#[must_use]
233pub(crate) fn emit_json(value: &serde_json::Value, kind: &str) -> ExitCode {
234 match serde_json::to_string_pretty(value) {
235 Ok(json) => {
236 outln!("{json}");
237 ExitCode::SUCCESS
238 }
239 Err(e) => {
240 eprintln!("Error: failed to serialize {kind} output: {e}");
241 ExitCode::from(2)
242 }
243 }
244}
245
246#[must_use]
248pub(crate) fn emit_report_json(
249 value: &serde_json::Value,
250 kind: &str,
251 style: crate::json_style::JsonStyle,
252) -> ExitCode {
253 match style.serialize(value) {
254 Ok(json) => {
255 outln!("{json}");
256 ExitCode::SUCCESS
257 }
258 Err(e) => {
259 eprintln!("Error: failed to serialize {kind} output: {e}");
260 ExitCode::from(2)
261 }
262 }
263}
264
265pub(crate) struct CheckJsonRenderInput<'a> {
266 pub(crate) package_baselines: &'a [fallow_api::PackageBaselineStatus],
267 pub(crate) results: &'a AnalysisResults,
268 pub(crate) root: &'a Path,
269 pub(crate) elapsed: Duration,
270 pub(crate) type_aware: Option<&'a fallow_types::envelope::TypeAwareMeta>,
271 pub(crate) regression: Option<&'a crate::regression::RegressionOutcome>,
272 pub(crate) baseline_matched: Option<(usize, usize)>,
273 pub(crate) baseline_staleness: Option<fallow_output::BaselineStaleness>,
274 pub(crate) gate_outcomes: Option<fallow_output::GateOutcomes>,
275 pub(crate) config_fixable: bool,
276 pub(crate) workspace_diagnostics: &'a [fallow_config::WorkspaceDiagnostic],
277 pub(crate) json_style: crate::json_style::JsonStyle,
278}
279
280pub(crate) fn render_check_json(
281 input: &CheckJsonRenderInput<'_>,
282) -> Result<String, serde_json::Error> {
283 json::render_json(&json::PrintJsonInput {
284 package_baselines: input.package_baselines,
285 results: input.results,
286 root: input.root,
287 elapsed: input.elapsed,
288 explain: false,
289 type_aware: input.type_aware,
290 regression: input.regression,
291 baseline_matched: input.baseline_matched,
292 baseline_staleness: input.baseline_staleness,
293 finding_id_query: None,
294 gate_outcomes: input.gate_outcomes.clone(),
295 config_fixable: input.config_fixable,
296 workspace_diagnostics: input.workspace_diagnostics,
297 json_style: input.json_style,
298 })
299}
300
301#[must_use]
307pub(crate) fn elide_common_prefix<'a>(base: &str, target: &'a str) -> &'a str {
308 let mut last_sep = 0;
309 for (i, (a, b)) in base.bytes().zip(target.bytes()).enumerate() {
310 if a != b {
311 break;
312 }
313 if a == b'/' {
314 last_sep = i + 1;
315 }
316 }
317 if last_sep > 0 && last_sep <= target.len() {
318 &target[last_sep..]
319 } else {
320 target
321 }
322}
323
324#[cfg(test)]
326fn relative_uri(path: &Path, root: &Path) -> String {
327 normalize_uri(&relative_path(path, root).display().to_string())
328}
329
330#[must_use]
335pub(crate) fn normalize_uri(path_str: &str) -> String {
336 fallow_output::normalize_uri(path_str)
337}
338
339#[derive(Clone, Copy, Debug)]
341pub enum Level {
342 Warn,
343 Info,
344 Error,
345}
346
347#[must_use]
348pub(crate) const fn severity_to_level(s: Severity) -> Level {
349 match s {
350 Severity::Error => Level::Error,
351 Severity::Warn => Level::Warn,
352 Severity::Off => Level::Info,
353 }
354}
355
356fn run_fails(ctx: &ReportContext<'_>) -> bool {
361 ctx.gate_outcomes
362 .as_ref()
363 .is_none_or(fallow_output::GateOutcomes::fails_run)
364}
365
366fn duplication_run_fails(ctx: &ReportContext<'_>) -> bool {
372 ctx.gate_outcomes
373 .as_ref()
374 .is_some_and(fallow_output::GateOutcomes::fails_run)
375}
376
377#[must_use]
383pub(crate) fn print_results(
384 results: &AnalysisResults,
385 ctx: &ReportContext<'_>,
386 output: OutputFormat,
387 regression: Option<&crate::regression::RegressionOutcome>,
388) -> ExitCode {
389 if let Some(ref resolver) = ctx.group_by {
390 let groups = grouping::group_analysis_results(results, ctx.root, resolver);
391 return print_grouped_results(&groups, results, ctx, output, resolver);
392 }
393
394 match output {
395 OutputFormat::Human => {
396 if ctx.summary {
397 human::check::print_check_summary(
398 results,
399 ctx.rules,
400 ctx.elapsed,
401 ctx.quiet,
402 ctx.summary_heading,
403 human::check::RunStatus {
404 run_fails: run_fails(ctx),
405 failed_parse_files: ctx.failed_parse_files,
406 },
407 );
408 } else {
409 human::print_human(&human::PrintHumanInput {
410 results,
411 root: ctx.root,
412 rules: ctx.rules,
413 elapsed: ctx.elapsed,
414 quiet: ctx.quiet,
415 top: ctx.top,
416 show_explain_tip: ctx.show_explain_tip,
417 explain: ctx.explain,
418 run_fails: run_fails(ctx),
419 failed_parse_files: ctx.failed_parse_files,
420 });
421 }
422 ExitCode::SUCCESS
423 }
424 OutputFormat::Json => json::print_json(&json::PrintJsonInput {
425 package_baselines: ctx.package_baselines,
426 results,
427 root: ctx.root,
428 elapsed: ctx.elapsed,
429 explain: ctx.explain,
430 type_aware: ctx.type_aware,
431 regression,
432 baseline_matched: ctx.baseline_matched,
433 baseline_staleness: ctx.baseline_staleness,
434 finding_id_query: ctx.finding_id_query.clone(),
435 gate_outcomes: ctx.gate_outcomes.clone(),
436 config_fixable: ctx.config_fixable,
437 workspace_diagnostics: ctx.workspace_diagnostics,
438 json_style: ctx.json_style,
439 }),
440 OutputFormat::Compact => {
441 compact::print_compact(results, ctx.root);
442 compact::print_type_aware_compact(ctx.type_aware, ctx.type_aware_scope);
443 ExitCode::SUCCESS
444 }
445 OutputFormat::Sarif => sarif::print_sarif(
446 results,
447 ctx.root,
448 ctx.rules,
449 ctx.type_aware,
450 ctx.workspace_diagnostics,
451 ),
452 OutputFormat::Markdown => {
453 markdown::print_markdown(results, ctx.root);
454 markdown::print_type_aware_markdown(ctx.type_aware, ctx.type_aware_scope);
455 markdown::print_config_pattern_markdown(ctx.workspace_diagnostics);
456 ExitCode::SUCCESS
457 }
458 OutputFormat::CodeClimate => codeclimate::print_codeclimate(results, ctx.root, ctx.rules),
459 OutputFormat::GithubAnnotations => print_check_github_annotations(results, ctx),
460 OutputFormat::GithubSummary => {
461 print_check_github_format(results, ctx, GithubTarget::Summary)
462 }
463 ci_format => print_results_ci_comment(results, ctx, ci_format),
464 }
465}
466
467#[derive(Clone, Copy)]
469enum GithubTarget {
470 Annotations,
471 Summary,
472}
473
474fn print_github_format(
475 kind: github_annotations::EnvelopeKind,
476 envelope: &serde_json::Value,
477 root: &Path,
478 target: GithubTarget,
479) -> ExitCode {
480 match target {
481 GithubTarget::Annotations => github_annotations::print_annotations(kind, envelope, root),
482 GithubTarget::Summary => github_summary::print_summary(kind, envelope, root),
483 }
484}
485
486fn print_check_github_annotations(results: &AnalysisResults, ctx: &ReportContext<'_>) -> ExitCode {
491 print_check_github_format(results, ctx, GithubTarget::Annotations)
492}
493
494fn print_check_github_format(
495 results: &AnalysisResults,
496 ctx: &ReportContext<'_>,
497 target: GithubTarget,
498) -> ExitCode {
499 match json::api_check_json_document_with_config_fixable_meta_and_extras(
500 results,
501 ctx.root,
502 ctx.elapsed,
503 ctx.config_fixable,
504 None,
505 fallow_api::CheckJsonExtraOutputs {
513 request_outcomes: crate::requests::request_outcomes(),
514 baseline_staleness: ctx.baseline_staleness,
515 gate_outcomes: ctx.gate_outcomes.clone(),
516 ..Default::default()
517 },
518 ctx.workspace_diagnostics,
519 ) {
520 Ok(envelope) => print_github_format(
521 github_annotations::EnvelopeKind::DeadCode,
522 &envelope,
523 ctx.root,
524 target,
525 ),
526 Err(e) => {
527 eprintln!("Error: failed to serialize results: {e}");
528 ExitCode::from(2)
529 }
530 }
531}
532
533pub fn ci_status_note(
545 existing: Option<&'static str>,
546 baseline_advisory: Option<&str>,
547 gates: Option<&fallow_output::GateOutcomes>,
548 requests: Option<&fallow_output::RequestOutcomes>,
549 grouping_dropped: Option<&str>,
550) -> Option<String> {
551 let gate_summary = gate_outcome_text::summary_line_for_gates(gates);
552 let request_summary = request_outcome_text::summary_line_for_requests(requests);
553 let grouping_clause = grouping_dropped.map(grouping_note::dropped_grouping_clause);
554 join_status_clauses(&[
555 existing,
556 baseline_advisory,
557 gate_summary.as_deref(),
558 request_summary.as_deref(),
559 grouping_clause.as_deref(),
560 ])
561}
562
563pub(crate) fn join_status_clauses(clauses: &[Option<&str>]) -> Option<String> {
572 let joined = clauses
573 .iter()
574 .filter_map(|clause| clause.filter(|value| !value.is_empty()))
575 .collect::<Vec<_>>()
576 .join(" ");
577 (!joined.is_empty()).then_some(joined)
578}
579
580fn print_results_ci_comment(
582 results: &AnalysisResults,
583 ctx: &ReportContext<'_>,
584 output: OutputFormat,
585) -> ExitCode {
586 let issues = codeclimate::api_codeclimate_issues(results, ctx.root, ctx.rules);
590 let incomplete = ci::required_type_aware_incomplete(ctx.type_aware);
591 let conclusion = incomplete.then_some(fallow_output::PrDecisionConclusion::Failure);
592 let advisory =
593 baseline_advisory_text::advisory_line_for_staleness(ctx.baseline_staleness.as_ref());
594 let requests = crate::requests::request_outcomes();
595 let grouping_dropped = dropped_grouping_mode(ctx, output);
596 let status_message = ci_status_note(
597 incomplete.then_some(ci::TYPE_AWARE_INCOMPLETE_MESSAGE),
598 advisory.as_deref(),
599 ctx.gate_outcomes.as_ref(),
600 requests.as_ref(),
601 grouping_dropped,
602 );
603 print_ci_comment_format_with_status(
604 "dead-code",
605 &issues,
606 output,
607 conclusion,
608 ci::pr_comment::PrCommentStatus {
609 message: status_message.as_deref(),
610 gates: &gate_outcome_text::gate_rows_for_gates(ctx.gate_outcomes.as_ref()),
611 config_patterns: ctx.workspace_diagnostics,
612 },
613 )
614 .unwrap_or_else(|| {
615 eprintln!("Error: badge format is only supported for the health command");
616 ExitCode::from(2)
617 })
618}
619
620#[must_use]
622fn print_grouped_results(
623 groups: &[grouping::ResultGroup],
624 original: &AnalysisResults,
625 ctx: &ReportContext<'_>,
626 output: OutputFormat,
627 resolver: &OwnershipResolver,
628) -> ExitCode {
629 match output {
630 OutputFormat::Human => {
631 human::print_grouped_human(&human::PrintGroupedHumanInput {
632 groups,
633 root: ctx.root,
634 rules: ctx.rules,
635 elapsed: ctx.elapsed,
636 quiet: ctx.quiet,
637 resolver: Some(resolver),
638 explain: ctx.explain,
639 run_fails: run_fails(ctx),
640 failed_parse_files: ctx.failed_parse_files,
641 });
642 ExitCode::SUCCESS
643 }
644 OutputFormat::Json => json::print_grouped_json(&json::PrintGroupedJsonInput {
645 package_baselines: ctx.package_baselines,
646 groups,
647 original,
648 root: ctx.root,
649 elapsed: ctx.elapsed,
650 explain: ctx.explain,
651 type_aware: ctx.type_aware,
652 resolver,
653 config_fixable: ctx.config_fixable,
654 baseline_staleness: ctx.baseline_staleness,
655 finding_id_query: ctx.finding_id_query.clone(),
656 gate_outcomes: ctx.gate_outcomes.clone(),
657 workspace_diagnostics: ctx.workspace_diagnostics,
658 json_style: ctx.json_style,
659 }),
660 OutputFormat::Compact => {
661 compact::print_grouped_compact(groups, ctx.root);
662 compact::print_type_aware_compact(ctx.type_aware, ctx.type_aware_scope);
663 ExitCode::SUCCESS
664 }
665 OutputFormat::Markdown => {
666 markdown::print_grouped_markdown(groups, ctx.root);
667 markdown::print_type_aware_markdown(ctx.type_aware, ctx.type_aware_scope);
668 markdown::print_config_pattern_markdown(ctx.workspace_diagnostics);
669 ExitCode::SUCCESS
670 }
671 OutputFormat::Sarif => sarif::print_grouped_sarif(
672 original,
673 ctx.root,
674 ctx.rules,
675 resolver,
676 ctx.type_aware,
677 ctx.workspace_diagnostics,
678 ),
679 OutputFormat::CodeClimate => {
680 codeclimate::print_grouped_codeclimate(original, ctx.root, ctx.rules, resolver)
681 }
682 OutputFormat::GithubAnnotations => {
692 dropped_grouping_mode(ctx, output);
693 print_check_github_annotations(original, ctx)
694 }
695 OutputFormat::GithubSummary => {
696 dropped_grouping_mode(ctx, output);
697 print_check_github_format(original, ctx, GithubTarget::Summary)
698 }
699 ci_format => print_results_ci_comment(original, ctx, ci_format),
700 }
701}
702
703#[must_use]
705pub(crate) fn print_duplication_report(
706 report: &DuplicationReport,
707 ctx: &ReportContext<'_>,
708 output: OutputFormat,
709) -> ExitCode {
710 if let Some(ref resolver) = ctx.group_by {
711 let grouping = dupes_grouping::build_duplication_grouping(report, ctx.root, resolver);
712 return print_grouped_duplication_report(report, &grouping, ctx, output, resolver);
713 }
714
715 match output {
716 OutputFormat::Human => {
717 if ctx.summary {
718 human::dupes::print_duplication_summary(
719 report,
720 ctx.elapsed,
721 ctx.quiet,
722 ctx.summary_heading,
723 duplication_run_fails(ctx),
724 );
725 } else {
726 human::print_duplication_human(
727 report,
728 ctx.root,
729 ctx.elapsed,
730 &human::dupes::DuplicationHumanOptions {
731 quiet: ctx.quiet,
732 show_explain_tip: ctx.show_explain_tip,
733 explain: ctx.explain,
734 run_fails: duplication_run_fails(ctx),
735 },
736 );
737 }
738 ExitCode::SUCCESS
739 }
740 OutputFormat::Json => json::print_duplication_json(
741 report,
742 ctx.root,
743 ctx.elapsed,
744 &json::DuplicationJsonRender {
745 package_baselines: ctx.package_baselines,
746 explain: ctx.explain,
747 include_fragments: ctx.include_fragments,
748 baseline_staleness: ctx.baseline_staleness,
749 gate_outcomes: ctx.gate_outcomes.clone(),
750 },
751 ctx.workspace_diagnostics,
752 ctx.json_style,
753 ),
754 OutputFormat::Compact => {
755 compact::print_duplication_compact(report, ctx.root);
756 ExitCode::SUCCESS
757 }
758 OutputFormat::Sarif => sarif::print_duplication_sarif(report, ctx.root),
759 OutputFormat::Markdown => {
760 markdown::print_duplication_markdown(report, ctx.root);
761 ExitCode::SUCCESS
762 }
763 OutputFormat::CodeClimate => codeclimate::print_duplication_codeclimate(report, ctx.root),
764 OutputFormat::GithubAnnotations => {
765 print_dupes_github_format(report, ctx, GithubTarget::Annotations)
766 }
767 OutputFormat::GithubSummary => {
768 print_dupes_github_format(report, ctx, GithubTarget::Summary)
769 }
770 ci_format => print_duplication_ci_comment(report, ctx.root, ci_format, ctx, None),
771 }
772}
773
774fn print_dupes_github_format(
777 report: &DuplicationReport,
778 ctx: &ReportContext<'_>,
779 target: GithubTarget,
780) -> ExitCode {
781 match json::api_duplication_json_document(
782 report,
783 ctx.root,
784 ctx.elapsed,
785 &json::DuplicationJsonRender {
786 package_baselines: ctx.package_baselines,
787 explain: ctx.explain,
788 include_fragments: ctx.include_fragments,
789 baseline_staleness: ctx.baseline_staleness,
790 gate_outcomes: ctx.gate_outcomes.clone(),
791 },
792 ctx.workspace_diagnostics,
793 ) {
794 Ok(envelope) => print_github_format(
795 github_annotations::EnvelopeKind::Dupes,
796 &envelope,
797 ctx.root,
798 target,
799 ),
800 Err(e) => {
801 eprintln!("Error: failed to serialize duplication report: {e}");
802 ExitCode::from(2)
803 }
804 }
805}
806
807fn print_duplication_ci_comment(
809 report: &DuplicationReport,
810 root: &Path,
811 output: OutputFormat,
812 ctx: &ReportContext<'_>,
813 grouping_dropped: Option<&str>,
814) -> ExitCode {
815 let issues = codeclimate::api_duplication_codeclimate_issues(report, root);
816 let advisory =
817 baseline_advisory_text::advisory_line_for_staleness(ctx.baseline_staleness.as_ref());
818 let requests = crate::requests::request_outcomes();
819 let note = ci_status_note(
820 None,
821 advisory.as_deref(),
822 ctx.gate_outcomes.as_ref(),
823 requests.as_ref(),
824 grouping_dropped,
825 );
826 print_ci_comment_format_with_status(
827 "dupes",
828 &issues,
829 output,
830 None,
831 ci::pr_comment::PrCommentStatus {
832 message: note.as_deref(),
833 gates: &gate_outcome_text::gate_rows_for_gates(ctx.gate_outcomes.as_ref()),
834 config_patterns: &[],
835 },
836 )
837 .unwrap_or_else(|| {
838 eprintln!("Error: badge format is only supported for the health command");
839 ExitCode::from(2)
840 })
841}
842
843#[must_use]
845fn print_grouped_duplication_report(
846 report: &DuplicationReport,
847 grouping: &DuplicationGrouping,
848 ctx: &ReportContext<'_>,
849 output: OutputFormat,
850 resolver: &OwnershipResolver,
851) -> ExitCode {
852 match output {
853 OutputFormat::Human => {
854 human::print_grouped_duplication_human(
855 report,
856 grouping,
857 ctx.root,
858 ctx.elapsed,
859 ctx.quiet,
860 duplication_run_fails(ctx),
861 );
862 ExitCode::SUCCESS
863 }
864 OutputFormat::Json => json::print_grouped_duplication_json(
865 report,
866 grouping,
867 ctx.root,
868 ctx.elapsed,
869 &json::DuplicationJsonRender {
870 package_baselines: ctx.package_baselines,
871 explain: ctx.explain,
872 include_fragments: ctx.include_fragments,
873 baseline_staleness: ctx.baseline_staleness,
874 gate_outcomes: ctx.gate_outcomes.clone(),
875 },
876 ctx.workspace_diagnostics,
877 ctx.json_style,
878 ),
879 OutputFormat::Sarif => sarif::print_grouped_duplication_sarif(report, ctx.root, resolver),
880 OutputFormat::CodeClimate => {
881 codeclimate::print_grouped_duplication_codeclimate(report, ctx.root, resolver)
882 }
883 OutputFormat::PrCommentGithub
884 | OutputFormat::PrCommentGitlab
885 | OutputFormat::ReviewGithub
886 | OutputFormat::ReviewGitlab => print_duplication_ci_comment(
887 report,
888 ctx.root,
889 output,
890 ctx,
891 note_dropped_grouping(Some(grouping.mode), output),
892 ),
893 OutputFormat::GithubAnnotations => {
900 note_dropped_grouping(Some(grouping.mode), output);
901 print_dupes_github_format(report, ctx, GithubTarget::Annotations)
902 }
903 OutputFormat::GithubSummary => {
904 note_dropped_grouping(Some(grouping.mode), output);
905 print_dupes_github_format(report, ctx, GithubTarget::Summary)
906 }
907 OutputFormat::Compact => {
908 compact::print_duplication_compact(report, ctx.root);
909 note_dropped_grouping(Some(grouping.mode), output);
910 ExitCode::SUCCESS
911 }
912 OutputFormat::Markdown => {
913 markdown::print_duplication_markdown(report, ctx.root);
914 note_dropped_grouping(Some(grouping.mode), output);
915 ExitCode::SUCCESS
916 }
917 OutputFormat::Badge => {
918 eprintln!("Error: badge format is only supported for the health command");
919 ExitCode::from(2)
920 }
921 }
922}
923
924fn print_ci_comment_format_with_status(
933 analysis: &str,
934 issues: &[fallow_output::CodeClimateIssue],
935 output: OutputFormat,
936 conclusion: Option<fallow_output::PrDecisionConclusion>,
937 status: ci::pr_comment::PrCommentStatus<'_>,
938) -> Option<ExitCode> {
939 let provider = match output {
940 OutputFormat::PrCommentGithub | OutputFormat::ReviewGithub => {
941 ci::pr_comment::Provider::Github
942 }
943 OutputFormat::PrCommentGitlab | OutputFormat::ReviewGitlab => {
944 ci::pr_comment::Provider::Gitlab
945 }
946 _ => return None,
947 };
948 let exit = if matches!(
949 output,
950 OutputFormat::PrCommentGithub | OutputFormat::PrCommentGitlab
951 ) {
952 ci::pr_comment::print_pr_comment_from_codeclimate_issues(
953 analysis, provider, issues, conclusion, status,
954 )
955 } else {
956 conclusion.map_or_else(
957 || {
958 ci::review::print_review_envelope_from_codeclimate_issues(
959 analysis,
960 provider,
961 issues,
962 status.into(),
963 )
964 },
965 |conclusion| {
966 ci::review::print_review_envelope_from_codeclimate_issues_with_conclusion(
967 analysis,
968 provider,
969 issues,
970 conclusion,
971 status.into(),
972 )
973 },
974 )
975 };
976 Some(exit)
977}
978
979fn note_dropped_grouping(mode: Option<&str>, output: OutputFormat) -> Option<&str> {
986 if let Some(mode) = mode {
987 eprintln!(
988 "note: --group-by {mode} is not supported for {format} output, falling back to \
989 ungrouped output (use --format json for the full grouped envelope)",
990 format = output.flag_label()
991 );
992 }
993 mode
994}
995
996fn dropped_grouping_mode<'a>(ctx: &'a ReportContext<'_>, output: OutputFormat) -> Option<&'a str> {
999 note_dropped_grouping(
1000 ctx.group_by
1001 .as_ref()
1002 .map(grouping::OwnershipResolver::mode_label),
1003 output,
1004 )
1005}
1006
1007#[must_use]
1024pub(crate) fn print_health_report(
1025 report: &fallow_output::HealthReport,
1026 grouping: Option<&fallow_output::HealthGrouping>,
1027 group_resolver: Option<&grouping::OwnershipResolver>,
1028 ctx: &ReportContext<'_>,
1029 output: OutputFormat,
1030) -> ExitCode {
1031 match output {
1032 OutputFormat::Human => {
1033 print_health_human_report(report, grouping, ctx);
1034 ExitCode::SUCCESS
1035 }
1036 OutputFormat::Compact => {
1037 compact::print_health_compact(report, ctx.root);
1038 compact::print_type_aware_compact(ctx.type_aware, ctx.type_aware_scope);
1039 dropped_health_grouping_mode(grouping, output);
1040 ExitCode::SUCCESS
1041 }
1042 OutputFormat::Markdown => {
1043 markdown::print_health_markdown(report, ctx.root);
1044 markdown::print_type_aware_markdown(ctx.type_aware, ctx.type_aware_scope);
1045 dropped_health_grouping_mode(grouping, output);
1046 ExitCode::SUCCESS
1047 }
1048 OutputFormat::Sarif => match group_resolver {
1049 Some(resolver) => {
1050 sarif::print_grouped_health_sarif(report, ctx.root, resolver, ctx.type_aware)
1051 }
1052 None => sarif::print_health_sarif(report, ctx.root, ctx.type_aware),
1053 },
1054 OutputFormat::Json => match grouping {
1055 Some(grouping) => json::print_grouped_health_json(
1056 report,
1057 grouping,
1058 ctx.root,
1059 ctx.elapsed,
1060 ctx.explain,
1061 ctx.type_aware,
1062 ctx.workspace_diagnostics,
1063 ctx.json_style,
1064 ctx.gate_outcomes.clone(),
1065 ),
1066 None => json::print_health_json(
1067 report,
1068 ctx.root,
1069 ctx.elapsed,
1070 ctx.explain,
1071 ctx.type_aware,
1072 ctx.workspace_diagnostics,
1073 ctx.json_style,
1074 ctx.gate_outcomes.clone(),
1075 ),
1076 },
1077 OutputFormat::CodeClimate => match group_resolver {
1078 Some(resolver) => {
1079 codeclimate::print_grouped_health_codeclimate(report, ctx.root, resolver)
1080 }
1081 None => codeclimate::print_health_codeclimate(report, ctx.root),
1082 },
1083 OutputFormat::PrCommentGithub
1084 | OutputFormat::PrCommentGitlab
1085 | OutputFormat::ReviewGithub
1086 | OutputFormat::ReviewGitlab => print_health_ci_comment(
1087 report,
1088 ctx.root,
1089 output,
1090 ctx,
1091 dropped_health_grouping_mode(grouping, output),
1092 ),
1093 OutputFormat::GithubAnnotations => {
1097 dropped_health_grouping_mode(grouping, output);
1098 print_health_github_format(report, ctx, GithubTarget::Annotations)
1099 }
1100 OutputFormat::GithubSummary => {
1101 dropped_health_grouping_mode(grouping, output);
1102 print_health_github_format(report, ctx, GithubTarget::Summary)
1103 }
1104 OutputFormat::Badge => {
1105 dropped_health_grouping_mode(grouping, output);
1106 badge::print_health_badge(report)
1107 }
1108 }
1109}
1110
1111fn print_health_github_format(
1114 report: &fallow_output::HealthReport,
1115 ctx: &ReportContext<'_>,
1116 target: GithubTarget,
1117) -> ExitCode {
1118 match json::api_health_json_document(
1119 report,
1120 ctx.root,
1121 ctx.elapsed,
1122 ctx.explain,
1123 ctx.type_aware,
1124 ctx.workspace_diagnostics,
1125 ctx.gate_outcomes.clone(),
1126 ) {
1127 Ok(envelope) => print_github_format(
1128 github_annotations::EnvelopeKind::Health,
1129 &envelope,
1130 ctx.root,
1131 target,
1132 ),
1133 Err(e) => {
1134 eprintln!("Error: failed to serialize health report: {e}");
1135 ExitCode::from(2)
1136 }
1137 }
1138}
1139
1140fn print_health_human_report(
1142 report: &fallow_output::HealthReport,
1143 grouping: Option<&fallow_output::HealthGrouping>,
1144 ctx: &ReportContext<'_>,
1145) {
1146 if ctx.summary {
1147 human::health::print_health_summary(report, ctx.elapsed, ctx.quiet, ctx.summary_heading);
1148 return;
1149 }
1150 human::print_health_human(&human::PrintHealthHumanInput {
1151 report,
1152 root: ctx.root,
1153 elapsed: ctx.elapsed,
1154 quiet: ctx.quiet,
1155 show_explain_tip: ctx.show_explain_tip,
1156 explain: ctx.explain,
1157 skip_score_and_trend: ctx.skip_score_and_trend,
1158 css_requested: ctx.css_requested,
1159 type_aware: ctx.type_aware,
1160 run_fails: run_fails(ctx),
1161 parse_degraded: &crate::gates::parse_degraded_files(ctx.root, ctx.workspace_diagnostics),
1162 });
1163 if let Some(grouping) = grouping {
1164 human::print_health_grouping(grouping, ctx.root, ctx.quiet);
1165 }
1166}
1167
1168fn print_health_ci_comment(
1170 report: &fallow_output::HealthReport,
1171 root: &Path,
1172 output: OutputFormat,
1173 ctx: &ReportContext<'_>,
1174 grouping_dropped: Option<&str>,
1175) -> ExitCode {
1176 let issues = codeclimate::api_health_codeclimate_issues(report, root);
1177 let advisory = baseline_advisory_text::advisory_line_for_staleness(
1181 report.summary.baseline_staleness.as_ref(),
1182 );
1183 let requests = crate::requests::request_outcomes();
1184 let note = ci_status_note(
1185 None,
1186 advisory.as_deref(),
1187 ctx.gate_outcomes.as_ref(),
1188 requests.as_ref(),
1189 grouping_dropped,
1190 );
1191 print_ci_comment_format_with_status(
1192 "health",
1193 &issues,
1194 output,
1195 None,
1196 ci::pr_comment::PrCommentStatus {
1197 message: note.as_deref(),
1198 gates: &gate_outcome_text::gate_rows_for_gates(ctx.gate_outcomes.as_ref()),
1199 config_patterns: &[],
1200 },
1201 )
1202 .unwrap_or_else(|| {
1203 eprintln!("Error: badge format is only supported for the health command");
1204 ExitCode::from(2)
1205 })
1206}
1207
1208fn dropped_health_grouping_mode(
1213 grouping: Option<&fallow_output::HealthGrouping>,
1214 output: OutputFormat,
1215) -> Option<&str> {
1216 note_dropped_grouping(grouping.map(|grouping| grouping.mode), output)
1217}
1218
1219pub(crate) fn print_cross_reference_findings(
1223 cross_ref: &fallow_engine::cross_reference::CrossReferenceResult,
1224 root: &Path,
1225 quiet: bool,
1226 output: OutputFormat,
1227) {
1228 human::print_cross_reference_findings(cross_ref, root, quiet, output);
1229}
1230
1231pub(crate) fn print_export_trace(
1233 trace: &ExportTrace,
1234 format: OutputFormat,
1235 json_style: crate::json_style::JsonStyle,
1236) {
1237 match format {
1238 OutputFormat::Json => json::print_trace_json(trace, json_style),
1239 _ => human::print_export_trace_human(trace),
1240 }
1241}
1242
1243pub(crate) fn print_semantic_export_trace(
1246 trace: &ExportTrace,
1247 format: OutputFormat,
1248 explain: bool,
1249 json_style: crate::json_style::JsonStyle,
1250) {
1251 match format {
1252 OutputFormat::Json => json::print_semantic_trace_json(trace, explain, json_style),
1253 _ => human::print_export_trace_human(trace),
1254 }
1255}
1256
1257pub(crate) fn print_class_member_trace(
1259 trace: &fallow_engine::trace::ClassMemberTrace,
1260 format: OutputFormat,
1261 json_style: crate::json_style::JsonStyle,
1262) {
1263 match format {
1264 OutputFormat::Json => json::print_trace_json(trace, json_style),
1265 _ => human::print_class_member_trace_human(trace),
1266 }
1267}
1268
1269pub(crate) fn print_semantic_class_member_trace(
1272 trace: &fallow_engine::trace::ClassMemberTrace,
1273 format: OutputFormat,
1274 explain: bool,
1275 json_style: crate::json_style::JsonStyle,
1276) {
1277 match format {
1278 OutputFormat::Json => json::print_semantic_trace_json(trace, explain, json_style),
1279 _ => human::print_class_member_trace_human(trace),
1280 }
1281}
1282
1283pub(crate) fn print_file_trace(
1285 trace: &FileTrace,
1286 format: OutputFormat,
1287 json_style: crate::json_style::JsonStyle,
1288) {
1289 match format {
1290 OutputFormat::Json => json::print_trace_json(trace, json_style),
1291 _ => human::print_file_trace_human(trace),
1292 }
1293}
1294
1295pub(crate) fn print_dependency_trace(
1297 trace: &DependencyTrace,
1298 format: OutputFormat,
1299 json_style: crate::json_style::JsonStyle,
1300) {
1301 match format {
1302 OutputFormat::Json => json::print_trace_json(trace, json_style),
1303 _ => human::print_dependency_trace_human(trace),
1304 }
1305}
1306
1307pub(crate) fn print_clone_trace(
1309 trace: &CloneTrace,
1310 root: &Path,
1311 format: OutputFormat,
1312 json_style: crate::json_style::JsonStyle,
1313) {
1314 match format {
1315 OutputFormat::Json => json::print_trace_json(trace, json_style),
1316 _ => human::print_clone_trace_human(trace, root),
1317 }
1318}
1319
1320pub(crate) fn print_impact_closure_trace(
1323 trace: &ImpactClosureTrace,
1324 format: OutputFormat,
1325 json_style: crate::json_style::JsonStyle,
1326) {
1327 match format {
1328 OutputFormat::Json => json::print_trace_json(trace, json_style),
1329 _ => {
1330 outln!("Impact closure for {}", trace.seed);
1331 outln!(
1332 " affected beyond the diff: {} file{}",
1333 trace.affected_not_shown.len(),
1334 plural(trace.affected_not_shown.len())
1335 );
1336 for gap in &trace.coordination_gap {
1337 outln!(
1338 " coordination gap: {} consumes {}",
1339 gap.consumer_file,
1340 gap.consumed_symbols.join(", ")
1341 );
1342 }
1343 }
1344 }
1345}
1346
1347pub(crate) fn print_symbol_impact(
1349 impact: &SemanticSymbolImpact,
1350 format: OutputFormat,
1351 explain: bool,
1352 json_style: crate::json_style::JsonStyle,
1353) {
1354 match format {
1355 OutputFormat::Json => json::print_semantic_impact_json(impact, explain, json_style),
1356 _ => human::print_symbol_impact_human(impact),
1357 }
1358}
1359
1360#[derive(serde::Serialize)]
1363struct PerformanceJson<'a> {
1364 #[serde(flatten)]
1365 timings: &'a PipelineTimings,
1366 spans: Vec<fallow_types::pipeline_spans::PipelineSpan>,
1367 #[serde(skip_serializing_if = "Option::is_none")]
1368 process: Option<fallow_types::pipeline_spans::ProcessTimings>,
1369}
1370
1371pub(crate) fn print_performance(
1378 timings: &PipelineTimings,
1379 process: Option<fallow_types::pipeline_spans::ProcessTimings>,
1380 duplication_concurrent: bool,
1381 format: OutputFormat,
1382 json_style: crate::json_style::JsonStyle,
1383) {
1384 match format {
1385 OutputFormat::Json => {
1386 let document = PerformanceJson {
1387 timings,
1388 spans: fallow_types::pipeline_spans::pipeline_span_tree(
1389 fallow_types::pipeline_spans::SpanTreeInput {
1390 timings,
1391 process: process.as_ref(),
1392 duplication_concurrent,
1393 },
1394 ),
1395 process,
1396 };
1397 match json_style.serialize(&document) {
1398 Ok(json) => eprintln!("{json}"),
1399 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
1400 }
1401 }
1402 _ => human::print_performance_human(timings, process.as_ref(), duplication_concurrent),
1403 }
1404}
1405
1406pub(crate) fn print_health_performance(
1409 timings: &fallow_output::HealthTimings,
1410 format: OutputFormat,
1411 json_style: crate::json_style::JsonStyle,
1412) {
1413 match format {
1414 OutputFormat::Json => match json_style.serialize(timings) {
1415 Ok(json) => eprintln!("{json}"),
1416 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
1417 },
1418 _ => human::print_health_performance_human(timings),
1419 }
1420}
1421
1422#[allow(
1423 unused_imports,
1424 reason = "target-dependent: used in lib, unused in bin"
1425)]
1426pub use fallow_api::build_compact_lines;
1427#[allow(
1428 unused_imports,
1429 reason = "target-dependent: used in lib, unused in bin"
1430)]
1431pub use fallow_api::build_duplication_markdown;
1432#[allow(
1433 unused_imports,
1434 reason = "target-dependent: used in lib, unused in bin"
1435)]
1436pub use fallow_api::build_health_markdown;
1437#[allow(
1438 unused_imports,
1439 reason = "target-dependent: used in lib, unused in bin"
1440)]
1441pub use fallow_api::build_markdown;
1442#[allow(
1443 clippy::redundant_pub_crate,
1444 reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
1445)]
1446pub(crate) use json::api_check_json_payload_with_config_fixable;
1447#[allow(
1448 clippy::redundant_pub_crate,
1449 reason = "target-dependent: report is public in lib, private in bin, but these adapters remain crate-internal"
1450)]
1451pub(crate) use json::{build_baseline_deltas_output, check_json_extras};
1452#[allow(
1453 unused_imports,
1454 reason = "target-dependent: used in lib, unused in bin"
1455)]
1456#[allow(
1457 clippy::redundant_pub_crate,
1458 reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
1459)]
1460pub(crate) use sarif::api_health_sarif_document;
1461#[allow(
1462 unused_imports,
1463 reason = "target-dependent: used in lib, unused in bin"
1464)]
1465#[allow(
1466 clippy::redundant_pub_crate,
1467 reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
1468)]
1469pub(crate) use sarif::api_sarif_document;
1470
1471#[cfg(test)]
1472mod tests {
1473 use super::*;
1474 use std::path::{Path, PathBuf};
1475
1476 #[test]
1477 fn format_bytes_pivots_at_power_of_1024() {
1478 assert_eq!(format_bytes(0), "0 B");
1479 assert_eq!(format_bytes(1023), "1023 B");
1480 assert_eq!(format_bytes(1024), "1 KiB");
1481 assert_eq!(format_bytes(2048), "2 KiB");
1482 assert_eq!(format_bytes(1_048_576), "1.0 MiB");
1483 assert_eq!(format_bytes(10_485_760), "10.0 MiB");
1484 assert_eq!(format_bytes(1_073_741_824), "1.0 GiB");
1485 }
1486
1487 fn test_context<'a>(root: &'a Path, rules: &'a RulesConfig) -> ReportContext<'a> {
1488 ReportContext {
1489 package_baselines: &[],
1490 baseline_staleness: None,
1491 finding_id_query: None,
1492 gate_outcomes: None,
1493 failed_parse_files: 0,
1494 root,
1495 rules,
1496 workspace_diagnostics: &[],
1497 elapsed: Duration::default(),
1498 quiet: true,
1499 explain: false,
1500 type_aware: None,
1501 type_aware_scope: None,
1502 group_by: None,
1503 top: None,
1504 summary: false,
1505 summary_heading: false,
1506 show_explain_tip: false,
1507 baseline_matched: None,
1508 config_fixable: false,
1509 skip_score_and_trend: false,
1510 css_requested: false,
1511 json_style: crate::json_style::JsonStyle::Compact,
1512 include_fragments: true,
1513 }
1514 }
1515
1516 #[test]
1517 fn normalize_uri_forward_slashes_unchanged() {
1518 assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
1519 }
1520
1521 #[test]
1522 fn normalize_uri_backslashes_replaced() {
1523 assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
1524 }
1525
1526 #[test]
1527 fn normalize_uri_mixed_slashes() {
1528 assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
1529 }
1530
1531 #[test]
1532 fn normalize_uri_path_with_spaces() {
1533 assert_eq!(
1534 normalize_uri("src\\my folder\\file.ts"),
1535 "src/my folder/file.ts"
1536 );
1537 }
1538
1539 #[test]
1540 fn normalize_uri_empty_string() {
1541 assert_eq!(normalize_uri(""), "");
1542 }
1543
1544 #[test]
1545 fn relative_path_strips_root_prefix() {
1546 let root = Path::new("/project");
1547 let path = Path::new("/project/src/utils.ts");
1548 assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
1549 }
1550
1551 #[test]
1552 fn relative_path_returns_full_path_when_no_prefix() {
1553 let root = Path::new("/other");
1554 let path = Path::new("/project/src/utils.ts");
1555 assert_eq!(relative_path(path, root), path);
1556 }
1557
1558 #[test]
1559 fn relative_path_at_root_returns_empty_or_file() {
1560 let root = Path::new("/project");
1561 let path = Path::new("/project/file.ts");
1562 assert_eq!(relative_path(path, root), Path::new("file.ts"));
1563 }
1564
1565 #[test]
1566 fn relative_path_deeply_nested() {
1567 let root = Path::new("/project");
1568 let path = Path::new("/project/packages/ui/src/components/Button.tsx");
1569 assert_eq!(
1570 relative_path(path, root),
1571 Path::new("packages/ui/src/components/Button.tsx")
1572 );
1573 }
1574
1575 #[test]
1576 fn format_display_path_returns_workspace_relative() {
1577 let root = Path::new("/project");
1578 let path = Path::new("/project/apps/server/src/index.ts");
1579 assert_eq!(format_display_path(path, root), "apps/server/src/index.ts");
1580 }
1581
1582 #[test]
1583 fn format_display_path_collides_in_nx_layout_renders_full_relative() {
1584 let root = Path::new("/project");
1585 let server = Path::new("/project/apps/server/src/index.ts");
1586 let client = Path::new("/project/apps/client/src/index.ts");
1587 assert_eq!(
1588 format_display_path(server, root),
1589 "apps/server/src/index.ts"
1590 );
1591 assert_eq!(
1592 format_display_path(client, root),
1593 "apps/client/src/index.ts"
1594 );
1595 }
1596
1597 #[test]
1598 fn format_display_path_angular_component_renders_parent_directory() {
1599 let root = Path::new("/project");
1600 let path = Path::new(
1601 "/project/apps/admin/src/app/payments/payment-list/payment-list.component.html",
1602 );
1603 assert_eq!(
1604 format_display_path(path, root),
1605 "apps/admin/src/app/payments/payment-list/payment-list.component.html"
1606 );
1607 }
1608
1609 #[test]
1610 fn format_display_path_falls_back_to_full_path_when_root_does_not_prefix() {
1611 let root = Path::new("/other");
1612 let path = Path::new("/project/src/utils.ts");
1613 let rendered = format_display_path(path, root);
1614 assert!(rendered.contains("project"));
1615 assert!(rendered.ends_with("utils.ts"));
1616 assert!(!rendered.contains('\\'));
1617 }
1618
1619 #[test]
1620 fn format_display_path_normalizes_backslashes_to_forward_slashes() {
1621 let root = Path::new("/project");
1622 let path = Path::new("/project/src/sub\\file.ts");
1623 let rendered = format_display_path(path, root);
1624 assert!(
1625 !rendered.contains('\\'),
1626 "backslashes must be normalized: {rendered}"
1627 );
1628 }
1629
1630 #[test]
1631 fn format_display_path_handles_brackets_verbatim() {
1632 let root = Path::new("/project");
1633 let path = Path::new("/project/app/[slug]/page.tsx");
1634 assert_eq!(format_display_path(path, root), "app/[slug]/page.tsx");
1635 }
1636
1637 #[test]
1638 fn format_display_path_path_equals_root_returns_empty() {
1639 let root = Path::new("/project");
1640 let path = Path::new("/project");
1641 assert_eq!(format_display_path(path, root), "");
1642 }
1643
1644 #[test]
1645 fn format_display_path_basename_only_when_path_is_at_root() {
1646 let root = Path::new("/project");
1647 let path = Path::new("/project/Cargo.toml");
1648 assert_eq!(format_display_path(path, root), "Cargo.toml");
1649 }
1650
1651 #[test]
1652 fn relative_uri_produces_forward_slash_path() {
1653 let root = PathBuf::from("/project");
1654 let path = root.join("src").join("utils.ts");
1655 let uri = relative_uri(&path, &root);
1656 assert_eq!(uri, "src/utils.ts");
1657 }
1658
1659 #[test]
1660 fn relative_uri_encodes_brackets() {
1661 let root = PathBuf::from("/project");
1662 let path = root.join("src/app/[...slug]/page.tsx");
1663 let uri = relative_uri(&path, &root);
1664 assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
1665 }
1666
1667 #[test]
1668 fn relative_uri_encodes_nested_dynamic_routes() {
1669 let root = PathBuf::from("/project");
1670 let path = root.join("src/app/[slug]/[id]/page.tsx");
1671 let uri = relative_uri(&path, &root);
1672 assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
1673 }
1674
1675 #[test]
1676 fn relative_uri_no_common_prefix_returns_full() {
1677 let root = PathBuf::from("/other");
1678 let path = PathBuf::from("/project/src/utils.ts");
1679 let uri = relative_uri(&path, &root);
1680 assert!(uri.contains("project"));
1681 assert!(uri.contains("utils.ts"));
1682 }
1683
1684 #[test]
1685 fn severity_error_maps_to_level_error() {
1686 assert!(matches!(severity_to_level(Severity::Error), Level::Error));
1687 }
1688
1689 #[test]
1690 fn severity_warn_maps_to_level_warn() {
1691 assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
1692 }
1693
1694 #[test]
1695 fn severity_off_maps_to_level_info() {
1696 assert!(matches!(severity_to_level(Severity::Off), Level::Info));
1697 }
1698
1699 #[test]
1700 fn normalize_uri_single_bracket_pair() {
1701 assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
1702 }
1703
1704 #[test]
1705 fn normalize_uri_catch_all_route() {
1706 assert_eq!(
1707 normalize_uri("app/[...slug]/page.tsx"),
1708 "app/%5B...slug%5D/page.tsx"
1709 );
1710 }
1711
1712 #[test]
1713 fn normalize_uri_optional_catch_all_route() {
1714 assert_eq!(
1715 normalize_uri("app/[[...slug]]/page.tsx"),
1716 "app/%5B%5B...slug%5D%5D/page.tsx"
1717 );
1718 }
1719
1720 #[test]
1721 fn normalize_uri_multiple_dynamic_segments() {
1722 assert_eq!(
1723 normalize_uri("app/[lang]/posts/[id]"),
1724 "app/%5Blang%5D/posts/%5Bid%5D"
1725 );
1726 }
1727
1728 #[test]
1729 fn normalize_uri_no_special_chars() {
1730 let plain = "src/components/Button.tsx";
1731 assert_eq!(normalize_uri(plain), plain);
1732 }
1733
1734 #[test]
1735 fn normalize_uri_only_backslashes() {
1736 assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
1737 }
1738
1739 #[test]
1740 fn relative_path_identical_paths_returns_empty() {
1741 let root = Path::new("/project");
1742 assert_eq!(relative_path(root, root), Path::new(""));
1743 }
1744
1745 #[test]
1746 fn relative_path_partial_name_match_not_stripped() {
1747 let root = Path::new("/project");
1748 let path = Path::new("/project-two/src/a.ts");
1749 assert_eq!(relative_path(path, root), path);
1750 }
1751
1752 #[test]
1753 fn relative_uri_combines_stripping_and_encoding() {
1754 let root = PathBuf::from("/project");
1755 let path = root.join("src/app/[slug]/page.tsx");
1756 let uri = relative_uri(&path, &root);
1757 assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
1758 assert!(!uri.starts_with('/'));
1759 }
1760
1761 #[test]
1762 fn relative_uri_at_root_file() {
1763 let root = PathBuf::from("/project");
1764 let path = root.join("index.ts");
1765 assert_eq!(relative_uri(&path, &root), "index.ts");
1766 }
1767
1768 #[test]
1769 fn severity_to_level_is_const_evaluable() {
1770 const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
1771 const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
1772 const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
1773 assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
1774 assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
1775 assert!(matches!(LEVEL_FROM_OFF, Level::Info));
1776 }
1777
1778 #[test]
1779 fn level_is_copy() {
1780 let level = severity_to_level(Severity::Error);
1781 let copy = level;
1782 assert!(matches!(level, Level::Error));
1783 assert!(matches!(copy, Level::Error));
1784 }
1785
1786 #[test]
1787 fn print_results_rejects_badge_for_dead_code_reports() {
1788 let root = Path::new("/project");
1789 let rules = RulesConfig::default();
1790 let ctx = test_context(root, &rules);
1791
1792 let code = print_results(&AnalysisResults::default(), &ctx, OutputFormat::Badge, None);
1793
1794 assert_eq!(code, ExitCode::from(2));
1795 }
1796
1797 #[test]
1798 fn print_duplication_report_rejects_badge_format() {
1799 let root = Path::new("/project");
1800 let rules = RulesConfig::default();
1801 let ctx = test_context(root, &rules);
1802
1803 let code =
1804 print_duplication_report(&DuplicationReport::default(), &ctx, OutputFormat::Badge);
1805
1806 assert_eq!(code, ExitCode::from(2));
1807 }
1808
1809 #[test]
1810 fn elide_common_prefix_shared_dir() {
1811 assert_eq!(
1812 elide_common_prefix("src/components/A.tsx", "src/components/B.tsx"),
1813 "B.tsx"
1814 );
1815 }
1816
1817 #[test]
1818 fn elide_common_prefix_partial_shared() {
1819 assert_eq!(
1820 elide_common_prefix("src/components/A.tsx", "src/utils/B.tsx"),
1821 "utils/B.tsx"
1822 );
1823 }
1824
1825 #[test]
1826 fn elide_common_prefix_no_shared() {
1827 assert_eq!(
1828 elide_common_prefix("pkg-a/src/A.tsx", "pkg-b/src/B.tsx"),
1829 "pkg-b/src/B.tsx"
1830 );
1831 }
1832
1833 #[test]
1834 fn elide_common_prefix_identical_files() {
1835 assert_eq!(elide_common_prefix("a/b/x.ts", "a/b/y.ts"), "y.ts");
1836 }
1837
1838 #[test]
1839 fn elide_common_prefix_no_dirs() {
1840 assert_eq!(elide_common_prefix("foo.ts", "bar.ts"), "bar.ts");
1841 }
1842
1843 #[test]
1844 fn elide_common_prefix_deep_monorepo() {
1845 assert_eq!(
1846 elide_common_prefix(
1847 "packages/rap/src/rap/components/SearchSelect/SearchSelect.tsx",
1848 "packages/rap/src/rap/components/SearchSelect/SearchSelectItem.tsx"
1849 ),
1850 "SearchSelectItem.tsx"
1851 );
1852 }
1853
1854 #[test]
1855 fn split_dir_filename_with_dir() {
1856 let (dir, file) = split_dir_filename("src/utils/index.ts");
1857 assert_eq!(dir, "src/utils/");
1858 assert_eq!(file, "index.ts");
1859 }
1860
1861 #[test]
1862 fn split_dir_filename_no_dir() {
1863 let (dir, file) = split_dir_filename("file.ts");
1864 assert_eq!(dir, "");
1865 assert_eq!(file, "file.ts");
1866 }
1867
1868 #[test]
1869 fn split_dir_filename_deeply_nested() {
1870 let (dir, file) = split_dir_filename("a/b/c/d/e.ts");
1871 assert_eq!(dir, "a/b/c/d/");
1872 assert_eq!(file, "e.ts");
1873 }
1874
1875 #[test]
1876 fn split_dir_filename_trailing_slash() {
1877 let (dir, file) = split_dir_filename("src/");
1878 assert_eq!(dir, "src/");
1879 assert_eq!(file, "");
1880 }
1881
1882 #[test]
1883 fn split_dir_filename_empty() {
1884 let (dir, file) = split_dir_filename("");
1885 assert_eq!(dir, "");
1886 assert_eq!(file, "");
1887 }
1888
1889 #[test]
1890 fn plural_zero_is_plural() {
1891 assert_eq!(plural(0), "s");
1892 }
1893
1894 #[test]
1895 fn plural_one_is_singular() {
1896 assert_eq!(plural(1), "");
1897 }
1898
1899 #[test]
1900 fn plural_two_is_plural() {
1901 assert_eq!(plural(2), "s");
1902 }
1903
1904 #[test]
1905 fn plural_large_number() {
1906 assert_eq!(plural(999), "s");
1907 }
1908
1909 #[test]
1910 fn elide_common_prefix_empty_base() {
1911 assert_eq!(elide_common_prefix("", "src/foo.ts"), "src/foo.ts");
1912 }
1913
1914 #[test]
1915 fn elide_common_prefix_empty_target() {
1916 assert_eq!(elide_common_prefix("src/foo.ts", ""), "");
1917 }
1918
1919 #[test]
1920 fn elide_common_prefix_both_empty() {
1921 assert_eq!(elide_common_prefix("", ""), "");
1922 }
1923
1924 #[test]
1925 fn elide_common_prefix_same_file_different_extension() {
1926 assert_eq!(
1927 elide_common_prefix("src/utils.ts", "src/utils.js"),
1928 "utils.js"
1929 );
1930 }
1931
1932 #[test]
1933 fn elide_common_prefix_partial_filename_match_not_stripped() {
1934 assert_eq!(
1935 elide_common_prefix("src/App.tsx", "src/AppUtils.tsx"),
1936 "AppUtils.tsx"
1937 );
1938 }
1939
1940 #[test]
1941 fn elide_common_prefix_identical_paths() {
1942 assert_eq!(elide_common_prefix("src/foo.ts", "src/foo.ts"), "foo.ts");
1943 }
1944
1945 #[test]
1946 fn split_dir_filename_single_slash() {
1947 let (dir, file) = split_dir_filename("/file.ts");
1948 assert_eq!(dir, "/");
1949 assert_eq!(file, "file.ts");
1950 }
1951
1952 #[test]
1953 fn emit_json_returns_success_for_valid_value() {
1954 let value = serde_json::json!({"key": "value"});
1955 let code = emit_json(&value, "test");
1956 assert_eq!(code, ExitCode::SUCCESS);
1957 }
1958
1959 mod proptests {
1960 use super::*;
1961 use proptest::prelude::*;
1962
1963 proptest! {
1964 #[test]
1966 fn split_dir_filename_reconstructs_path(path in "[a-zA-Z0-9_./\\-]{0,100}") {
1967 let (dir, file) = split_dir_filename(&path);
1968 let reconstructed = format!("{dir}{file}");
1969 prop_assert_eq!(
1970 reconstructed, path,
1971 "dir+file should reconstruct the original path"
1972 );
1973 }
1974
1975 #[test]
1977 fn plural_returns_empty_or_s(n: usize) {
1978 let result = plural(n);
1979 prop_assert!(
1980 result.is_empty() || result == "s",
1981 "plural should return \"\" or \"s\", got {:?}",
1982 result
1983 );
1984 }
1985
1986 #[test]
1988 fn plural_singular_only_for_one(n: usize) {
1989 let result = plural(n);
1990 if n == 1 {
1991 prop_assert_eq!(result, "", "plural(1) should be empty");
1992 } else {
1993 prop_assert_eq!(result, "s", "plural({}) should be \"s\"", n);
1994 }
1995 }
1996
1997 #[test]
1999 fn normalize_uri_no_backslashes(path in "[a-zA-Z0-9_.\\\\/ \\[\\]%-]{0,100}") {
2000 let result = normalize_uri(&path);
2001 prop_assert!(
2002 !result.contains('\\'),
2003 "Result should not contain backslashes: {result}"
2004 );
2005 }
2006
2007 #[test]
2009 fn normalize_uri_encodes_all_brackets(path in "[a-zA-Z0-9_./\\[\\]%-]{0,80}") {
2010 let result = normalize_uri(&path);
2011 prop_assert!(
2012 !result.contains('[') && !result.contains(']'),
2013 "Result should not contain raw brackets: {result}"
2014 );
2015 }
2016
2017 #[test]
2019 fn elide_common_prefix_returns_suffix_of_target(
2020 base in "[a-zA-Z0-9_./]{0,50}",
2021 target in "[a-zA-Z0-9_./]{0,50}",
2022 ) {
2023 let result = elide_common_prefix(&base, &target);
2024 prop_assert!(
2025 target.ends_with(result),
2026 "Result {:?} should be a suffix of target {:?}",
2027 result, target
2028 );
2029 }
2030
2031 #[test]
2033 fn relative_path_never_panics(
2034 root in "/[a-zA-Z0-9_/]{0,30}",
2035 suffix in "[a-zA-Z0-9_./]{0,30}",
2036 ) {
2037 let root_path = Path::new(&root);
2038 let full = PathBuf::from(format!("{root}/{suffix}"));
2039 let _ = relative_path(&full, root_path);
2040 }
2041 }
2042 }
2043}