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 human::{build_section_header, format_path};
50pub(crate) use markdown::{print_saved_markdown, saved_markdown_unsupported};
51pub(crate) use status::{
52 HumanStatus, line as human_status_line, semantic_status, type_aware_meta_status,
53};
54
55pub(crate) struct WalkthroughHumanRender {
61 pub(crate) header: Vec<String>,
63 pub(crate) body: Vec<String>,
65 pub(crate) status: String,
67}
68
69#[must_use]
74pub(crate) fn walkthrough_viewed_files(
75 guide: &fallow_output::StandardWalkthroughGuide,
76 viewed: &crate::walkthrough_state::ViewedState,
77) -> Vec<String> {
78 human::walkthrough::viewed_files_for(guide, viewed)
79}
80
81#[must_use]
85pub(crate) fn build_walkthrough_human(
86 guide: &fallow_output::StandardWalkthroughGuide,
87 viewed: &crate::walkthrough_state::ViewedState,
88 show_cleared: bool,
89) -> WalkthroughHumanRender {
90 let input = human::walkthrough::WalkthroughHumanInput {
91 guide,
92 viewed,
93 show_cleared,
94 };
95 WalkthroughHumanRender {
96 header: human::walkthrough::build_focus_header(guide, viewed),
97 body: human::walkthrough::build_walkthrough_human_lines(&input),
98 status: human::walkthrough::build_status_line(guide, viewed),
99 }
100}
101
102pub(crate) struct ReportContext<'a> {
107 pub(crate) package_baselines: &'a [fallow_api::PackageBaselineStatus],
108 pub(crate) root: &'a Path,
109 pub(crate) rules: &'a RulesConfig,
110 pub(crate) workspace_diagnostics: &'a [fallow_config::WorkspaceDiagnostic],
112 pub(crate) elapsed: Duration,
113 pub(crate) quiet: bool,
114 pub(crate) explain: bool,
115 pub(crate) type_aware: Option<&'a fallow_types::envelope::TypeAwareMeta>,
117 pub(crate) type_aware_scope: Option<&'static str>,
120 pub(crate) group_by: Option<OwnershipResolver>,
122 pub(crate) top: Option<usize>,
124 pub(crate) summary: bool,
126 pub(crate) summary_heading: bool,
130 pub(crate) show_explain_tip: bool,
132 pub(crate) baseline_matched: Option<(usize, usize)>,
134 pub(crate) baseline_staleness: Option<fallow_output::BaselineStaleness>,
139 pub(crate) finding_id_query: Option<fallow_output::FindingIdQuery>,
142 pub(crate) gate_outcomes: Option<fallow_output::GateOutcomes>,
145 pub(crate) failed_parse_files: usize,
149 pub(crate) config_fixable: bool,
154 pub(crate) skip_score_and_trend: bool,
159 pub(crate) css_requested: bool,
163 pub(crate) json_style: crate::json_style::JsonStyle,
165 pub(crate) include_fragments: bool,
169 pub(crate) architecture_layout: ArchitectureLayout,
172}
173
174#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
181pub(crate) enum ArchitectureLayout {
182 #[default]
184 Embedded,
185 Split,
187 Only,
189}
190
191pub(crate) const ARCHITECTURE_COMMAND: &str = "architecture";
193
194impl ArchitectureLayout {
195 pub(crate) const fn command(self) -> &'static str {
198 match self {
199 Self::Only => ARCHITECTURE_COMMAND,
200 Self::Embedded | Self::Split => "dead-code",
201 }
202 }
203
204 pub(crate) const fn envelope(self) -> fallow_output::CheckEnvelope {
211 match self {
212 Self::Only => fallow_output::CheckEnvelope::Architecture,
213 Self::Embedded | Self::Split => fallow_output::CheckEnvelope::DeadCode,
214 }
215 }
216}
217
218pub(crate) fn has_architecture_findings(results: &fallow_types::results::AnalysisResults) -> bool {
221 !results.circular_dependencies.is_empty()
222 || !results.re_export_cycles.is_empty()
223 || !results.package_cycles.is_empty()
224 || !results.boundary_violations.is_empty()
225 || !results.boundary_coverage_violations.is_empty()
226 || !results.boundary_call_violations.is_empty()
227 || !results.policy_violations.is_empty()
228}
229
230#[must_use]
232pub(crate) fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
233 path.strip_prefix(root).unwrap_or(path)
234}
235
236#[must_use]
246pub(crate) fn format_display_path(path: &Path, root: &Path) -> String {
247 relative_path(path, root)
248 .display()
249 .to_string()
250 .replace('\\', "/")
251}
252
253#[must_use]
256pub(crate) fn split_dir_filename(path: &str) -> (&str, &str) {
257 path.rfind('/')
258 .map_or(("", path), |pos| (&path[..=pos], &path[pos + 1..]))
259}
260
261#[must_use]
263pub(crate) const fn plural(n: usize) -> &'static str {
264 if n == 1 { "" } else { "s" }
265}
266
267#[expect(
270 clippy::cast_precision_loss,
271 reason = "reported byte counts are well under the f64 precision loss range"
272)]
273#[must_use]
274pub(crate) fn format_bytes(bytes: u64) -> String {
275 const KIB: u64 = 1024;
276 const MIB: u64 = KIB * 1024;
277 const GIB: u64 = MIB * 1024;
278 if bytes >= GIB {
279 format!("{:.1} GiB", bytes as f64 / GIB as f64)
280 } else if bytes >= MIB {
281 format!("{:.1} MiB", bytes as f64 / MIB as f64)
282 } else if bytes >= KIB {
283 format!("{:.0} KiB", bytes as f64 / KIB as f64)
284 } else {
285 format!("{bytes} B")
286 }
287}
288
289#[must_use]
294pub(crate) fn emit_json(value: &serde_json::Value, kind: &str) -> ExitCode {
295 match serde_json::to_string_pretty(value) {
296 Ok(json) => {
297 outln!("{json}");
298 ExitCode::SUCCESS
299 }
300 Err(e) => {
301 eprintln!("Error: failed to serialize {kind} output: {e}");
302 ExitCode::from(2)
303 }
304 }
305}
306
307#[must_use]
309pub(crate) fn emit_report_json(
310 value: &serde_json::Value,
311 kind: &str,
312 style: crate::json_style::JsonStyle,
313) -> ExitCode {
314 match style.serialize(value) {
315 Ok(json) => {
316 outln!("{json}");
317 ExitCode::SUCCESS
318 }
319 Err(e) => {
320 eprintln!("Error: failed to serialize {kind} output: {e}");
321 ExitCode::from(2)
322 }
323 }
324}
325
326pub(crate) struct CheckJsonRenderInput<'a> {
327 pub(crate) package_baselines: &'a [fallow_api::PackageBaselineStatus],
328 pub(crate) results: &'a AnalysisResults,
329 pub(crate) root: &'a Path,
330 pub(crate) elapsed: Duration,
331 pub(crate) type_aware: Option<&'a fallow_types::envelope::TypeAwareMeta>,
332 pub(crate) regression: Option<&'a crate::regression::RegressionOutcome>,
333 pub(crate) baseline_matched: Option<(usize, usize)>,
334 pub(crate) baseline_staleness: Option<fallow_output::BaselineStaleness>,
335 pub(crate) gate_outcomes: Option<fallow_output::GateOutcomes>,
336 pub(crate) config_fixable: bool,
337 pub(crate) workspace_diagnostics: &'a [fallow_config::WorkspaceDiagnostic],
338 pub(crate) json_style: crate::json_style::JsonStyle,
339}
340
341pub(crate) fn render_check_json(
342 input: &CheckJsonRenderInput<'_>,
343) -> Result<String, serde_json::Error> {
344 json::render_json(&json::PrintJsonInput {
345 package_baselines: input.package_baselines,
346 results: input.results,
347 root: input.root,
348 elapsed: input.elapsed,
349 explain: false,
350 type_aware: input.type_aware,
351 regression: input.regression,
352 baseline_matched: input.baseline_matched,
353 baseline_staleness: input.baseline_staleness,
354 finding_id_query: None,
355 gate_outcomes: input.gate_outcomes.clone(),
356 config_fixable: input.config_fixable,
357 workspace_diagnostics: input.workspace_diagnostics,
358 json_style: input.json_style,
359 envelope: fallow_output::CheckEnvelope::DeadCode,
360 })
361}
362
363#[must_use]
369pub(crate) fn elide_common_prefix<'a>(base: &str, target: &'a str) -> &'a str {
370 let mut last_sep = 0;
371 for (i, (a, b)) in base.bytes().zip(target.bytes()).enumerate() {
372 if a != b {
373 break;
374 }
375 if a == b'/' {
376 last_sep = i + 1;
377 }
378 }
379 if last_sep > 0 && last_sep <= target.len() {
380 &target[last_sep..]
381 } else {
382 target
383 }
384}
385
386#[cfg(test)]
388fn relative_uri(path: &Path, root: &Path) -> String {
389 normalize_uri(&relative_path(path, root).display().to_string())
390}
391
392#[must_use]
397pub(crate) fn normalize_uri(path_str: &str) -> String {
398 fallow_output::normalize_uri(path_str)
399}
400
401#[derive(Clone, Copy, Debug)]
403pub enum Level {
404 Warn,
405 Info,
406 Error,
407}
408
409#[must_use]
410pub(crate) const fn severity_to_level(s: Severity) -> Level {
411 match s {
412 Severity::Error => Level::Error,
413 Severity::Warn => Level::Warn,
414 Severity::Off => Level::Info,
415 }
416}
417
418fn run_fails(ctx: &ReportContext<'_>) -> bool {
423 ctx.gate_outcomes
424 .as_ref()
425 .is_none_or(fallow_output::GateOutcomes::fails_run)
426}
427
428fn duplication_run_fails(ctx: &ReportContext<'_>) -> bool {
434 ctx.gate_outcomes
435 .as_ref()
436 .is_some_and(fallow_output::GateOutcomes::fails_run)
437}
438
439#[must_use]
445pub(crate) fn print_results(
446 results: &AnalysisResults,
447 ctx: &ReportContext<'_>,
448 output: OutputFormat,
449 regression: Option<&crate::regression::RegressionOutcome>,
450) -> ExitCode {
451 if let Some(ref resolver) = ctx.group_by {
452 let groups = grouping::group_analysis_results(results, ctx.root, resolver);
453 return print_grouped_results(&groups, results, ctx, output, resolver);
454 }
455
456 match output {
457 OutputFormat::Human => {
458 if ctx.summary {
459 human::check::print_check_summary(
460 results,
461 ctx.rules,
462 ctx.elapsed,
463 ctx.quiet,
464 human::check::SummaryStyle {
465 heading: ctx.summary_heading.then_some(
466 if ctx.architecture_layout == ArchitectureLayout::Only {
467 "Architecture Summary"
468 } else {
469 "Dead Code Summary"
470 },
471 ),
472 layout: ctx.architecture_layout,
473 },
474 human::check::RunStatus {
475 run_fails: run_fails(ctx),
476 failed_parse_files: ctx.failed_parse_files,
477 },
478 );
479 } else {
480 human::print_human(&human::PrintHumanInput {
481 results,
482 root: ctx.root,
483 rules: ctx.rules,
484 elapsed: ctx.elapsed,
485 quiet: ctx.quiet,
486 top: ctx.top,
487 show_explain_tip: ctx.show_explain_tip,
488 explain: ctx.explain,
489 run_fails: run_fails(ctx),
490 failed_parse_files: ctx.failed_parse_files,
491 architecture_layout: ctx.architecture_layout,
492 });
493 }
494 ExitCode::SUCCESS
495 }
496 OutputFormat::Json => json::print_json(&json::PrintJsonInput {
497 package_baselines: ctx.package_baselines,
498 results,
499 root: ctx.root,
500 elapsed: ctx.elapsed,
501 explain: ctx.explain,
502 type_aware: ctx.type_aware,
503 regression,
504 baseline_matched: ctx.baseline_matched,
505 baseline_staleness: ctx.baseline_staleness,
506 finding_id_query: ctx.finding_id_query.clone(),
507 gate_outcomes: ctx.gate_outcomes.clone(),
508 config_fixable: ctx.config_fixable,
509 workspace_diagnostics: ctx.workspace_diagnostics,
510 json_style: ctx.json_style,
511 envelope: ctx.architecture_layout.envelope(),
512 }),
513 OutputFormat::Compact => {
514 compact::print_compact(results, ctx.root);
515 compact::print_type_aware_compact(ctx.type_aware, ctx.type_aware_scope);
516 ExitCode::SUCCESS
517 }
518 OutputFormat::Sarif => sarif::print_sarif(
519 results,
520 ctx.root,
521 ctx.rules,
522 ctx.type_aware,
523 ctx.workspace_diagnostics,
524 ),
525 OutputFormat::Markdown => {
526 markdown::print_markdown(results, ctx.root);
527 markdown::print_type_aware_markdown(ctx.type_aware, ctx.type_aware_scope);
528 markdown::print_config_pattern_markdown(ctx.workspace_diagnostics);
529 ExitCode::SUCCESS
530 }
531 OutputFormat::CodeClimate => codeclimate::print_codeclimate(results, ctx.root, ctx.rules),
532 OutputFormat::GithubAnnotations => print_check_github_annotations(results, ctx),
533 OutputFormat::GithubSummary => {
534 print_check_github_format(results, ctx, GithubTarget::Summary)
535 }
536 ci_format => print_results_ci_comment(results, ctx, ci_format),
537 }
538}
539
540#[derive(Clone, Copy)]
542enum GithubTarget {
543 Annotations,
544 Summary,
545}
546
547fn print_github_format(
548 kind: github_annotations::EnvelopeKind,
549 envelope: &serde_json::Value,
550 root: &Path,
551 target: GithubTarget,
552) -> ExitCode {
553 match target {
554 GithubTarget::Annotations => github_annotations::print_annotations(kind, envelope, root),
555 GithubTarget::Summary => github_summary::print_summary(kind, envelope, root),
556 }
557}
558
559fn print_check_github_annotations(results: &AnalysisResults, ctx: &ReportContext<'_>) -> ExitCode {
564 print_check_github_format(results, ctx, GithubTarget::Annotations)
565}
566
567fn print_check_github_format(
568 results: &AnalysisResults,
569 ctx: &ReportContext<'_>,
570 target: GithubTarget,
571) -> ExitCode {
572 match json::api_check_json_document_with_config_fixable_meta_and_extras(
573 results,
574 ctx.root,
575 ctx.elapsed,
576 ctx.config_fixable,
577 None,
578 fallow_api::CheckJsonExtraOutputs {
586 request_outcomes: crate::requests::request_outcomes(),
587 baseline_staleness: ctx.baseline_staleness,
588 gate_outcomes: ctx.gate_outcomes.clone(),
589 ..Default::default()
590 },
591 ctx.workspace_diagnostics,
592 ctx.architecture_layout.envelope(),
593 ) {
594 Ok(envelope) => print_github_format(
595 ctx.architecture_layout.envelope().into(),
596 &envelope,
597 ctx.root,
598 target,
599 ),
600 Err(e) => {
601 eprintln!("Error: failed to serialize results: {e}");
602 ExitCode::from(2)
603 }
604 }
605}
606
607pub fn ci_status_note(
619 existing: Option<&'static str>,
620 baseline_advisory: Option<&str>,
621 gates: Option<&fallow_output::GateOutcomes>,
622 requests: Option<&fallow_output::RequestOutcomes>,
623 grouping_dropped: Option<&str>,
624) -> Option<String> {
625 let gate_summary = gate_outcome_text::summary_line_for_gates(gates);
626 let request_summary = request_outcome_text::summary_line_for_requests(requests);
627 let grouping_clause = grouping_dropped.map(grouping_note::dropped_grouping_clause);
628 join_status_clauses(&[
629 existing,
630 baseline_advisory,
631 gate_summary.as_deref(),
632 request_summary.as_deref(),
633 grouping_clause.as_deref(),
634 ])
635}
636
637pub(crate) fn join_status_clauses(clauses: &[Option<&str>]) -> Option<String> {
646 let joined = clauses
647 .iter()
648 .filter_map(|clause| clause.filter(|value| !value.is_empty()))
649 .collect::<Vec<_>>()
650 .join(" ");
651 (!joined.is_empty()).then_some(joined)
652}
653
654fn print_results_ci_comment(
656 results: &AnalysisResults,
657 ctx: &ReportContext<'_>,
658 output: OutputFormat,
659) -> ExitCode {
660 let issues = codeclimate::api_codeclimate_issues(results, ctx.root, ctx.rules);
664 let incomplete = ci::required_type_aware_incomplete(ctx.type_aware);
665 let conclusion = incomplete.then_some(fallow_output::PrDecisionConclusion::Failure);
666 let advisory =
667 baseline_advisory_text::advisory_line_for_staleness(ctx.baseline_staleness.as_ref());
668 let requests = crate::requests::request_outcomes();
669 let grouping_dropped = dropped_grouping_mode(ctx, output);
670 let status_message = ci_status_note(
671 incomplete.then_some(ci::TYPE_AWARE_INCOMPLETE_MESSAGE),
672 advisory.as_deref(),
673 ctx.gate_outcomes.as_ref(),
674 requests.as_ref(),
675 grouping_dropped,
676 );
677 print_ci_comment_format_with_status(
678 ctx.architecture_layout.command(),
679 &issues,
680 output,
681 conclusion,
682 ci::pr_comment::PrCommentStatus {
683 message: status_message.as_deref(),
684 gates: &gate_outcome_text::gate_rows_for_gates(ctx.gate_outcomes.as_ref()),
685 config_patterns: ctx.workspace_diagnostics,
686 },
687 )
688 .unwrap_or_else(|| {
689 eprintln!("Error: badge format is only supported for the health command");
690 ExitCode::from(2)
691 })
692}
693
694#[must_use]
696fn print_grouped_results(
697 groups: &[grouping::ResultGroup],
698 original: &AnalysisResults,
699 ctx: &ReportContext<'_>,
700 output: OutputFormat,
701 resolver: &OwnershipResolver,
702) -> ExitCode {
703 match output {
704 OutputFormat::Human => {
705 human::print_grouped_human(&human::PrintGroupedHumanInput {
706 groups,
707 root: ctx.root,
708 rules: ctx.rules,
709 elapsed: ctx.elapsed,
710 quiet: ctx.quiet,
711 resolver: Some(resolver),
712 explain: ctx.explain,
713 run_fails: run_fails(ctx),
714 failed_parse_files: ctx.failed_parse_files,
715 architecture_layout: ctx.architecture_layout,
716 });
717 ExitCode::SUCCESS
718 }
719 OutputFormat::Json => json::print_grouped_json(&json::PrintGroupedJsonInput {
720 package_baselines: ctx.package_baselines,
721 groups,
722 original,
723 root: ctx.root,
724 elapsed: ctx.elapsed,
725 explain: ctx.explain,
726 type_aware: ctx.type_aware,
727 resolver,
728 config_fixable: ctx.config_fixable,
729 baseline_staleness: ctx.baseline_staleness,
730 finding_id_query: ctx.finding_id_query.clone(),
731 gate_outcomes: ctx.gate_outcomes.clone(),
732 workspace_diagnostics: ctx.workspace_diagnostics,
733 json_style: ctx.json_style,
734 envelope: ctx.architecture_layout.envelope(),
735 }),
736 OutputFormat::Compact => {
737 compact::print_grouped_compact(groups, ctx.root);
738 compact::print_type_aware_compact(ctx.type_aware, ctx.type_aware_scope);
739 ExitCode::SUCCESS
740 }
741 OutputFormat::Markdown => {
742 markdown::print_grouped_markdown(groups, ctx.root, original.cascade_hidden);
743 markdown::print_type_aware_markdown(ctx.type_aware, ctx.type_aware_scope);
744 markdown::print_config_pattern_markdown(ctx.workspace_diagnostics);
745 ExitCode::SUCCESS
746 }
747 OutputFormat::Sarif => sarif::print_grouped_sarif(
748 original,
749 ctx.root,
750 ctx.rules,
751 resolver,
752 ctx.type_aware,
753 ctx.workspace_diagnostics,
754 ),
755 OutputFormat::CodeClimate => {
756 codeclimate::print_grouped_codeclimate(original, ctx.root, ctx.rules, resolver)
757 }
758 OutputFormat::GithubAnnotations => {
768 dropped_grouping_mode(ctx, output);
769 print_check_github_annotations(original, ctx)
770 }
771 OutputFormat::GithubSummary => {
772 dropped_grouping_mode(ctx, output);
773 print_check_github_format(original, ctx, GithubTarget::Summary)
774 }
775 ci_format => print_results_ci_comment(original, ctx, ci_format),
776 }
777}
778
779#[must_use]
781pub(crate) fn print_duplication_report(
782 report: &DuplicationReport,
783 ctx: &ReportContext<'_>,
784 output: OutputFormat,
785) -> ExitCode {
786 if let Some(ref resolver) = ctx.group_by {
787 let grouping = dupes_grouping::build_duplication_grouping(report, ctx.root, resolver);
788 return print_grouped_duplication_report(report, &grouping, ctx, output, resolver);
789 }
790
791 match output {
792 OutputFormat::Human => {
793 if ctx.summary {
794 human::dupes::print_duplication_summary(
795 report,
796 ctx.elapsed,
797 ctx.quiet,
798 ctx.summary_heading,
799 duplication_run_fails(ctx),
800 );
801 } else {
802 human::print_duplication_human(
803 report,
804 ctx.root,
805 ctx.elapsed,
806 &human::dupes::DuplicationHumanOptions {
807 quiet: ctx.quiet,
808 show_explain_tip: ctx.show_explain_tip,
809 explain: ctx.explain,
810 run_fails: duplication_run_fails(ctx),
811 },
812 );
813 }
814 ExitCode::SUCCESS
815 }
816 OutputFormat::Json => json::print_duplication_json(
817 report,
818 ctx.root,
819 ctx.elapsed,
820 &json::DuplicationJsonRender {
821 package_baselines: ctx.package_baselines,
822 explain: ctx.explain,
823 include_fragments: ctx.include_fragments,
824 baseline_staleness: ctx.baseline_staleness,
825 gate_outcomes: ctx.gate_outcomes.clone(),
826 },
827 ctx.workspace_diagnostics,
828 ctx.json_style,
829 ),
830 OutputFormat::Compact => {
831 compact::print_duplication_compact(report, ctx.root);
832 ExitCode::SUCCESS
833 }
834 OutputFormat::Sarif => sarif::print_duplication_sarif(report, ctx.root),
835 OutputFormat::Markdown => {
836 markdown::print_duplication_markdown(report, ctx.root);
837 ExitCode::SUCCESS
838 }
839 OutputFormat::CodeClimate => codeclimate::print_duplication_codeclimate(report, ctx.root),
840 OutputFormat::GithubAnnotations => {
841 print_dupes_github_format(report, ctx, GithubTarget::Annotations)
842 }
843 OutputFormat::GithubSummary => {
844 print_dupes_github_format(report, ctx, GithubTarget::Summary)
845 }
846 ci_format => print_duplication_ci_comment(report, ctx.root, ci_format, ctx, None),
847 }
848}
849
850fn print_dupes_github_format(
853 report: &DuplicationReport,
854 ctx: &ReportContext<'_>,
855 target: GithubTarget,
856) -> ExitCode {
857 match json::api_duplication_json_document(
858 report,
859 ctx.root,
860 ctx.elapsed,
861 &json::DuplicationJsonRender {
862 package_baselines: ctx.package_baselines,
863 explain: ctx.explain,
864 include_fragments: ctx.include_fragments,
865 baseline_staleness: ctx.baseline_staleness,
866 gate_outcomes: ctx.gate_outcomes.clone(),
867 },
868 ctx.workspace_diagnostics,
869 ) {
870 Ok(envelope) => print_github_format(
871 github_annotations::EnvelopeKind::Dupes,
872 &envelope,
873 ctx.root,
874 target,
875 ),
876 Err(e) => {
877 eprintln!("Error: failed to serialize duplication report: {e}");
878 ExitCode::from(2)
879 }
880 }
881}
882
883fn print_duplication_ci_comment(
885 report: &DuplicationReport,
886 root: &Path,
887 output: OutputFormat,
888 ctx: &ReportContext<'_>,
889 grouping_dropped: Option<&str>,
890) -> ExitCode {
891 let issues = codeclimate::api_duplication_codeclimate_issues(report, root);
892 let advisory =
893 baseline_advisory_text::advisory_line_for_staleness(ctx.baseline_staleness.as_ref());
894 let requests = crate::requests::request_outcomes();
895 let note = ci_status_note(
896 None,
897 advisory.as_deref(),
898 ctx.gate_outcomes.as_ref(),
899 requests.as_ref(),
900 grouping_dropped,
901 );
902 print_ci_comment_format_with_status(
903 "dupes",
904 &issues,
905 output,
906 None,
907 ci::pr_comment::PrCommentStatus {
908 message: note.as_deref(),
909 gates: &gate_outcome_text::gate_rows_for_gates(ctx.gate_outcomes.as_ref()),
910 config_patterns: &[],
911 },
912 )
913 .unwrap_or_else(|| {
914 eprintln!("Error: badge format is only supported for the health command");
915 ExitCode::from(2)
916 })
917}
918
919#[must_use]
921fn print_grouped_duplication_report(
922 report: &DuplicationReport,
923 grouping: &DuplicationGrouping,
924 ctx: &ReportContext<'_>,
925 output: OutputFormat,
926 resolver: &OwnershipResolver,
927) -> ExitCode {
928 match output {
929 OutputFormat::Human => {
930 human::print_grouped_duplication_human(
931 report,
932 grouping,
933 ctx.root,
934 ctx.elapsed,
935 ctx.quiet,
936 duplication_run_fails(ctx),
937 );
938 ExitCode::SUCCESS
939 }
940 OutputFormat::Json => json::print_grouped_duplication_json(
941 report,
942 grouping,
943 ctx.root,
944 ctx.elapsed,
945 &json::DuplicationJsonRender {
946 package_baselines: ctx.package_baselines,
947 explain: ctx.explain,
948 include_fragments: ctx.include_fragments,
949 baseline_staleness: ctx.baseline_staleness,
950 gate_outcomes: ctx.gate_outcomes.clone(),
951 },
952 ctx.workspace_diagnostics,
953 ctx.json_style,
954 ),
955 OutputFormat::Sarif => sarif::print_grouped_duplication_sarif(report, ctx.root, resolver),
956 OutputFormat::CodeClimate => {
957 codeclimate::print_grouped_duplication_codeclimate(report, ctx.root, resolver)
958 }
959 OutputFormat::PrCommentGithub
960 | OutputFormat::PrCommentGitlab
961 | OutputFormat::ReviewGithub
962 | OutputFormat::ReviewGitlab => print_duplication_ci_comment(
963 report,
964 ctx.root,
965 output,
966 ctx,
967 note_dropped_grouping(Some(grouping.mode), output),
968 ),
969 OutputFormat::GithubAnnotations => {
976 note_dropped_grouping(Some(grouping.mode), output);
977 print_dupes_github_format(report, ctx, GithubTarget::Annotations)
978 }
979 OutputFormat::GithubSummary => {
980 note_dropped_grouping(Some(grouping.mode), output);
981 print_dupes_github_format(report, ctx, GithubTarget::Summary)
982 }
983 OutputFormat::Compact => {
984 compact::print_duplication_compact(report, ctx.root);
985 note_dropped_grouping(Some(grouping.mode), output);
986 ExitCode::SUCCESS
987 }
988 OutputFormat::Markdown => {
989 markdown::print_duplication_markdown(report, ctx.root);
990 note_dropped_grouping(Some(grouping.mode), output);
991 ExitCode::SUCCESS
992 }
993 OutputFormat::Badge => {
994 eprintln!("Error: badge format is only supported for the health command");
995 ExitCode::from(2)
996 }
997 }
998}
999
1000fn print_ci_comment_format_with_status(
1009 analysis: &str,
1010 issues: &[fallow_output::CodeClimateIssue],
1011 output: OutputFormat,
1012 conclusion: Option<fallow_output::PrDecisionConclusion>,
1013 status: ci::pr_comment::PrCommentStatus<'_>,
1014) -> Option<ExitCode> {
1015 let provider = match output {
1016 OutputFormat::PrCommentGithub | OutputFormat::ReviewGithub => {
1017 ci::pr_comment::Provider::Github
1018 }
1019 OutputFormat::PrCommentGitlab | OutputFormat::ReviewGitlab => {
1020 ci::pr_comment::Provider::Gitlab
1021 }
1022 _ => return None,
1023 };
1024 let exit = if matches!(
1025 output,
1026 OutputFormat::PrCommentGithub | OutputFormat::PrCommentGitlab
1027 ) {
1028 ci::pr_comment::print_pr_comment_from_codeclimate_issues(
1029 analysis, provider, issues, conclusion, status,
1030 )
1031 } else {
1032 conclusion.map_or_else(
1033 || {
1034 ci::review::print_review_envelope_from_codeclimate_issues(
1035 analysis,
1036 provider,
1037 issues,
1038 status.into(),
1039 )
1040 },
1041 |conclusion| {
1042 ci::review::print_review_envelope_from_codeclimate_issues_with_conclusion(
1043 analysis,
1044 provider,
1045 issues,
1046 conclusion,
1047 status.into(),
1048 )
1049 },
1050 )
1051 };
1052 Some(exit)
1053}
1054
1055fn note_dropped_grouping(mode: Option<&str>, output: OutputFormat) -> Option<&str> {
1062 if let Some(mode) = mode {
1063 eprintln!(
1064 "note: --group-by {mode} is not supported for {format} output, falling back to \
1065 ungrouped output (use --format json for the full grouped envelope)",
1066 format = output.flag_label()
1067 );
1068 }
1069 mode
1070}
1071
1072fn dropped_grouping_mode<'a>(ctx: &'a ReportContext<'_>, output: OutputFormat) -> Option<&'a str> {
1075 note_dropped_grouping(
1076 ctx.group_by
1077 .as_ref()
1078 .map(grouping::OwnershipResolver::mode_label),
1079 output,
1080 )
1081}
1082
1083#[must_use]
1102pub(crate) fn print_health_report(
1103 report: &fallow_output::HealthReport,
1104 grouping: Option<&fallow_output::HealthGrouping>,
1105 group_resolver: Option<&grouping::OwnershipResolver>,
1106 ctx: &ReportContext<'_>,
1107 output: OutputFormat,
1108) -> ExitCode {
1109 match output {
1110 OutputFormat::Human => {
1111 print_health_human_report(report, grouping, ctx);
1112 ExitCode::SUCCESS
1113 }
1114 OutputFormat::Compact => {
1115 compact::print_health_compact(report, ctx.root);
1116 compact::print_type_aware_compact(ctx.type_aware, ctx.type_aware_scope);
1117 dropped_health_grouping_mode(grouping, output);
1118 ExitCode::SUCCESS
1119 }
1120 OutputFormat::Markdown => {
1121 markdown::print_health_markdown(report, ctx.root);
1122 if let Some(grouping) = grouping {
1123 markdown::print_health_grouping_markdown(grouping, ctx.root);
1124 }
1125 markdown::print_type_aware_markdown(ctx.type_aware, ctx.type_aware_scope);
1126 ExitCode::SUCCESS
1127 }
1128 OutputFormat::Sarif => match group_resolver {
1129 Some(resolver) => {
1130 sarif::print_grouped_health_sarif(report, ctx.root, resolver, ctx.type_aware)
1131 }
1132 None => sarif::print_health_sarif(report, ctx.root, ctx.type_aware),
1133 },
1134 OutputFormat::Json => match grouping {
1135 Some(grouping) => json::print_grouped_health_json(
1136 report,
1137 grouping,
1138 ctx.root,
1139 ctx.elapsed,
1140 ctx.explain,
1141 ctx.type_aware,
1142 ctx.workspace_diagnostics,
1143 ctx.json_style,
1144 ctx.gate_outcomes.clone(),
1145 ),
1146 None => json::print_health_json(
1147 report,
1148 ctx.root,
1149 ctx.elapsed,
1150 ctx.explain,
1151 ctx.type_aware,
1152 ctx.workspace_diagnostics,
1153 ctx.json_style,
1154 ctx.gate_outcomes.clone(),
1155 ),
1156 },
1157 OutputFormat::CodeClimate => match group_resolver {
1158 Some(resolver) => {
1159 codeclimate::print_grouped_health_codeclimate(report, ctx.root, resolver)
1160 }
1161 None => codeclimate::print_health_codeclimate(report, ctx.root),
1162 },
1163 OutputFormat::PrCommentGithub
1164 | OutputFormat::PrCommentGitlab
1165 | OutputFormat::ReviewGithub
1166 | OutputFormat::ReviewGitlab => print_health_ci_comment(
1167 report,
1168 ctx.root,
1169 output,
1170 ctx,
1171 dropped_health_grouping_mode(grouping, output),
1172 ),
1173 OutputFormat::GithubAnnotations => {
1177 dropped_health_grouping_mode(grouping, output);
1178 print_health_github_format(report, None, ctx, GithubTarget::Annotations)
1179 }
1180 OutputFormat::GithubSummary => {
1183 print_health_github_format(report, grouping, ctx, GithubTarget::Summary)
1184 }
1185 OutputFormat::Badge => {
1186 dropped_health_grouping_mode(grouping, output);
1187 badge::print_health_badge(report)
1188 }
1189 }
1190}
1191
1192fn print_health_github_format(
1195 report: &fallow_output::HealthReport,
1196 grouping: Option<&fallow_output::HealthGrouping>,
1197 ctx: &ReportContext<'_>,
1198 target: GithubTarget,
1199) -> ExitCode {
1200 let document = match grouping {
1201 Some(grouping) => json::api_grouped_health_json_document(
1202 report,
1203 grouping,
1204 ctx.root,
1205 ctx.elapsed,
1206 ctx.explain,
1207 ctx.type_aware,
1208 ctx.workspace_diagnostics,
1209 ctx.gate_outcomes.clone(),
1210 ),
1211 None => json::api_health_json_document(
1212 report,
1213 ctx.root,
1214 ctx.elapsed,
1215 ctx.explain,
1216 ctx.type_aware,
1217 ctx.workspace_diagnostics,
1218 ctx.gate_outcomes.clone(),
1219 ),
1220 };
1221 match document {
1222 Ok(envelope) => print_github_format(
1223 github_annotations::EnvelopeKind::Health,
1224 &envelope,
1225 ctx.root,
1226 target,
1227 ),
1228 Err(e) => {
1229 eprintln!("Error: failed to serialize health report: {e}");
1230 ExitCode::from(2)
1231 }
1232 }
1233}
1234
1235fn print_health_human_report(
1237 report: &fallow_output::HealthReport,
1238 grouping: Option<&fallow_output::HealthGrouping>,
1239 ctx: &ReportContext<'_>,
1240) {
1241 if ctx.summary {
1242 human::health::print_health_summary(report, ctx.elapsed, ctx.quiet, ctx.summary_heading);
1243 return;
1244 }
1245 human::print_health_human(&human::PrintHealthHumanInput {
1246 report,
1247 root: ctx.root,
1248 elapsed: ctx.elapsed,
1249 quiet: ctx.quiet,
1250 show_explain_tip: ctx.show_explain_tip,
1251 explain: ctx.explain,
1252 skip_score_and_trend: ctx.skip_score_and_trend,
1253 css_requested: ctx.css_requested,
1254 type_aware: ctx.type_aware,
1255 run_fails: run_fails(ctx),
1256 parse_degraded: &crate::gates::parse_degraded_files(ctx.root, ctx.workspace_diagnostics),
1257 });
1258 if let Some(grouping) = grouping {
1259 human::print_health_grouping(grouping, ctx.root, ctx.quiet);
1260 }
1261}
1262
1263fn print_health_ci_comment(
1265 report: &fallow_output::HealthReport,
1266 root: &Path,
1267 output: OutputFormat,
1268 ctx: &ReportContext<'_>,
1269 grouping_dropped: Option<&str>,
1270) -> ExitCode {
1271 let issues = codeclimate::api_health_codeclimate_issues(report, root);
1272 let advisory = baseline_advisory_text::advisory_line_for_staleness(
1276 report.summary.baseline_staleness.as_ref(),
1277 );
1278 let requests = crate::requests::request_outcomes();
1279 let note = ci_status_note(
1280 None,
1281 advisory.as_deref(),
1282 ctx.gate_outcomes.as_ref(),
1283 requests.as_ref(),
1284 grouping_dropped,
1285 );
1286 print_ci_comment_format_with_status(
1287 "health",
1288 &issues,
1289 output,
1290 None,
1291 ci::pr_comment::PrCommentStatus {
1292 message: note.as_deref(),
1293 gates: &gate_outcome_text::gate_rows_for_gates(ctx.gate_outcomes.as_ref()),
1294 config_patterns: &[],
1295 },
1296 )
1297 .unwrap_or_else(|| {
1298 eprintln!("Error: badge format is only supported for the health command");
1299 ExitCode::from(2)
1300 })
1301}
1302
1303fn dropped_health_grouping_mode(
1308 grouping: Option<&fallow_output::HealthGrouping>,
1309 output: OutputFormat,
1310) -> Option<&str> {
1311 note_dropped_grouping(grouping.map(|grouping| grouping.mode), output)
1312}
1313
1314pub(crate) fn print_cross_reference_findings(
1318 cross_ref: &fallow_engine::cross_reference::CrossReferenceResult,
1319 root: &Path,
1320 quiet: bool,
1321 output: OutputFormat,
1322) {
1323 human::print_cross_reference_findings(cross_ref, root, quiet, output);
1324}
1325
1326pub(crate) fn print_export_trace(
1328 trace: &ExportTrace,
1329 format: OutputFormat,
1330 json_style: crate::json_style::JsonStyle,
1331) {
1332 match format {
1333 OutputFormat::Json => json::print_trace_json(trace, json_style),
1334 _ => human::print_export_trace_human(trace),
1335 }
1336}
1337
1338pub(crate) fn print_semantic_export_trace(
1341 trace: &ExportTrace,
1342 format: OutputFormat,
1343 explain: bool,
1344 json_style: crate::json_style::JsonStyle,
1345) {
1346 match format {
1347 OutputFormat::Json => json::print_semantic_trace_json(trace, explain, json_style),
1348 _ => human::print_export_trace_human(trace),
1349 }
1350}
1351
1352pub(crate) fn print_class_member_trace(
1354 trace: &fallow_engine::trace::ClassMemberTrace,
1355 format: OutputFormat,
1356 json_style: crate::json_style::JsonStyle,
1357) {
1358 match format {
1359 OutputFormat::Json => json::print_trace_json(trace, json_style),
1360 _ => human::print_class_member_trace_human(trace),
1361 }
1362}
1363
1364pub(crate) fn print_semantic_class_member_trace(
1367 trace: &fallow_engine::trace::ClassMemberTrace,
1368 format: OutputFormat,
1369 explain: bool,
1370 json_style: crate::json_style::JsonStyle,
1371) {
1372 match format {
1373 OutputFormat::Json => json::print_semantic_trace_json(trace, explain, json_style),
1374 _ => human::print_class_member_trace_human(trace),
1375 }
1376}
1377
1378pub(crate) fn print_file_trace(
1380 trace: &FileTrace,
1381 format: OutputFormat,
1382 json_style: crate::json_style::JsonStyle,
1383) {
1384 match format {
1385 OutputFormat::Json => json::print_trace_json(trace, json_style),
1386 _ => human::print_file_trace_human(trace),
1387 }
1388}
1389
1390pub(crate) fn print_dependency_trace(
1392 trace: &DependencyTrace,
1393 format: OutputFormat,
1394 json_style: crate::json_style::JsonStyle,
1395) {
1396 match format {
1397 OutputFormat::Json => json::print_trace_json(trace, json_style),
1398 _ => human::print_dependency_trace_human(trace),
1399 }
1400}
1401
1402pub(crate) fn print_dependency_usage_trace_human(trace: &DependencyTrace, quiet: bool) {
1405 human::print_dependency_usage_trace_human(trace, quiet);
1406}
1407
1408pub(crate) fn print_clone_trace(
1410 trace: &CloneTrace,
1411 root: &Path,
1412 format: OutputFormat,
1413 json_style: crate::json_style::JsonStyle,
1414) {
1415 match format {
1416 OutputFormat::Json => json::print_trace_json(trace, json_style),
1417 _ => human::print_clone_trace_human(trace, root),
1418 }
1419}
1420
1421pub(crate) fn print_impact_closure_trace(
1424 trace: &ImpactClosureTrace,
1425 format: OutputFormat,
1426 json_style: crate::json_style::JsonStyle,
1427) {
1428 match format {
1429 OutputFormat::Json => json::print_trace_json(trace, json_style),
1430 _ => {
1431 outln!("Impact closure for {}", trace.seed);
1432 outln!(
1433 " affected beyond the diff: {} file{}",
1434 trace.affected_not_shown.len(),
1435 plural(trace.affected_not_shown.len())
1436 );
1437 for gap in &trace.coordination_gap {
1438 outln!(
1439 " coordination gap: {} consumes {}{}",
1440 gap.consumer_file,
1441 gap.consumed_symbols.join(", "),
1442 human::traces::gap_search_hint(gap)
1443 );
1444 }
1445 }
1446 }
1447}
1448
1449pub(crate) fn print_symbol_impact(
1451 impact: &SemanticSymbolImpact,
1452 format: OutputFormat,
1453 explain: bool,
1454 json_style: crate::json_style::JsonStyle,
1455) {
1456 match format {
1457 OutputFormat::Json => json::print_semantic_impact_json(impact, explain, json_style),
1458 _ => human::print_symbol_impact_human(impact),
1459 }
1460}
1461
1462#[derive(serde::Serialize)]
1465struct PerformanceJson<'a> {
1466 #[serde(flatten)]
1467 timings: &'a PipelineTimings,
1468 spans: Vec<fallow_types::pipeline_spans::PipelineSpan>,
1469 #[serde(skip_serializing_if = "Option::is_none")]
1470 process: Option<fallow_types::pipeline_spans::ProcessTimings>,
1471}
1472
1473pub(crate) fn print_performance(
1480 timings: &PipelineTimings,
1481 process: Option<fallow_types::pipeline_spans::ProcessTimings>,
1482 duplication_concurrent: bool,
1483 format: OutputFormat,
1484 json_style: crate::json_style::JsonStyle,
1485) {
1486 match format {
1487 OutputFormat::Json => {
1488 let document = PerformanceJson {
1489 timings,
1490 spans: fallow_types::pipeline_spans::pipeline_span_tree(
1491 fallow_types::pipeline_spans::SpanTreeInput {
1492 timings,
1493 process: process.as_ref(),
1494 duplication_concurrent,
1495 },
1496 ),
1497 process,
1498 };
1499 match json_style.serialize(&document) {
1500 Ok(json) => eprintln!("{json}"),
1501 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
1502 }
1503 }
1504 _ => human::print_performance_human(timings, process.as_ref(), duplication_concurrent),
1505 }
1506}
1507
1508pub(crate) fn print_health_performance(
1511 timings: &fallow_output::HealthTimings,
1512 format: OutputFormat,
1513 json_style: crate::json_style::JsonStyle,
1514) {
1515 match format {
1516 OutputFormat::Json => match json_style.serialize(timings) {
1517 Ok(json) => eprintln!("{json}"),
1518 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
1519 },
1520 _ => human::print_health_performance_human(timings),
1521 }
1522}
1523
1524#[allow(
1525 unused_imports,
1526 reason = "target-dependent: used in lib, unused in bin"
1527)]
1528pub use fallow_api::build_compact_lines;
1529#[allow(
1530 unused_imports,
1531 reason = "target-dependent: used in lib, unused in bin"
1532)]
1533pub use fallow_api::build_duplication_markdown;
1534#[allow(
1535 unused_imports,
1536 reason = "target-dependent: used in lib, unused in bin"
1537)]
1538pub use fallow_api::build_health_markdown;
1539#[allow(
1540 unused_imports,
1541 reason = "target-dependent: used in lib, unused in bin"
1542)]
1543pub use fallow_api::build_markdown;
1544#[allow(
1545 clippy::redundant_pub_crate,
1546 reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
1547)]
1548pub(crate) use json::api_check_json_payload_with_config_fixable;
1549#[allow(
1550 clippy::redundant_pub_crate,
1551 reason = "target-dependent: report is public in lib, private in bin, but these adapters remain crate-internal"
1552)]
1553pub(crate) use json::{build_baseline_deltas_output, check_json_extras};
1554#[allow(
1555 unused_imports,
1556 reason = "target-dependent: used in lib, unused in bin"
1557)]
1558#[allow(
1559 clippy::redundant_pub_crate,
1560 reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
1561)]
1562pub(crate) use sarif::api_health_sarif_document;
1563#[allow(
1564 unused_imports,
1565 reason = "target-dependent: used in lib, unused in bin"
1566)]
1567#[allow(
1568 clippy::redundant_pub_crate,
1569 reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
1570)]
1571pub(crate) use sarif::api_sarif_document;
1572
1573#[cfg(test)]
1574mod tests {
1575 use super::*;
1576 use std::path::{Path, PathBuf};
1577
1578 #[test]
1579 fn format_bytes_pivots_at_power_of_1024() {
1580 assert_eq!(format_bytes(0), "0 B");
1581 assert_eq!(format_bytes(1023), "1023 B");
1582 assert_eq!(format_bytes(1024), "1 KiB");
1583 assert_eq!(format_bytes(2048), "2 KiB");
1584 assert_eq!(format_bytes(1_048_576), "1.0 MiB");
1585 assert_eq!(format_bytes(10_485_760), "10.0 MiB");
1586 assert_eq!(format_bytes(1_073_741_824), "1.0 GiB");
1587 }
1588
1589 fn test_context<'a>(root: &'a Path, rules: &'a RulesConfig) -> ReportContext<'a> {
1590 ReportContext {
1591 package_baselines: &[],
1592 baseline_staleness: None,
1593 finding_id_query: None,
1594 gate_outcomes: None,
1595 failed_parse_files: 0,
1596 root,
1597 rules,
1598 workspace_diagnostics: &[],
1599 elapsed: Duration::default(),
1600 quiet: true,
1601 explain: false,
1602 type_aware: None,
1603 type_aware_scope: None,
1604 group_by: None,
1605 top: None,
1606 summary: false,
1607 summary_heading: false,
1608 show_explain_tip: false,
1609 baseline_matched: None,
1610 config_fixable: false,
1611 skip_score_and_trend: false,
1612 css_requested: false,
1613 json_style: crate::json_style::JsonStyle::Compact,
1614 include_fragments: true,
1615 architecture_layout: ArchitectureLayout::Embedded,
1616 }
1617 }
1618
1619 #[test]
1620 fn normalize_uri_forward_slashes_unchanged() {
1621 assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
1622 }
1623
1624 #[test]
1625 fn normalize_uri_backslashes_replaced() {
1626 assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
1627 }
1628
1629 #[test]
1630 fn normalize_uri_mixed_slashes() {
1631 assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
1632 }
1633
1634 #[test]
1635 fn normalize_uri_path_with_spaces() {
1636 assert_eq!(
1637 normalize_uri("src\\my folder\\file.ts"),
1638 "src/my folder/file.ts"
1639 );
1640 }
1641
1642 #[test]
1643 fn normalize_uri_empty_string() {
1644 assert_eq!(normalize_uri(""), "");
1645 }
1646
1647 #[test]
1648 fn relative_path_strips_root_prefix() {
1649 let root = Path::new("/project");
1650 let path = Path::new("/project/src/utils.ts");
1651 assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
1652 }
1653
1654 #[test]
1655 fn relative_path_returns_full_path_when_no_prefix() {
1656 let root = Path::new("/other");
1657 let path = Path::new("/project/src/utils.ts");
1658 assert_eq!(relative_path(path, root), path);
1659 }
1660
1661 #[test]
1662 fn relative_path_at_root_returns_empty_or_file() {
1663 let root = Path::new("/project");
1664 let path = Path::new("/project/file.ts");
1665 assert_eq!(relative_path(path, root), Path::new("file.ts"));
1666 }
1667
1668 #[test]
1669 fn relative_path_deeply_nested() {
1670 let root = Path::new("/project");
1671 let path = Path::new("/project/packages/ui/src/components/Button.tsx");
1672 assert_eq!(
1673 relative_path(path, root),
1674 Path::new("packages/ui/src/components/Button.tsx")
1675 );
1676 }
1677
1678 #[test]
1679 fn format_display_path_returns_workspace_relative() {
1680 let root = Path::new("/project");
1681 let path = Path::new("/project/apps/server/src/index.ts");
1682 assert_eq!(format_display_path(path, root), "apps/server/src/index.ts");
1683 }
1684
1685 #[test]
1686 fn format_display_path_collides_in_nx_layout_renders_full_relative() {
1687 let root = Path::new("/project");
1688 let server = Path::new("/project/apps/server/src/index.ts");
1689 let client = Path::new("/project/apps/client/src/index.ts");
1690 assert_eq!(
1691 format_display_path(server, root),
1692 "apps/server/src/index.ts"
1693 );
1694 assert_eq!(
1695 format_display_path(client, root),
1696 "apps/client/src/index.ts"
1697 );
1698 }
1699
1700 #[test]
1701 fn format_display_path_angular_component_renders_parent_directory() {
1702 let root = Path::new("/project");
1703 let path = Path::new(
1704 "/project/apps/admin/src/app/payments/payment-list/payment-list.component.html",
1705 );
1706 assert_eq!(
1707 format_display_path(path, root),
1708 "apps/admin/src/app/payments/payment-list/payment-list.component.html"
1709 );
1710 }
1711
1712 #[test]
1713 fn format_display_path_falls_back_to_full_path_when_root_does_not_prefix() {
1714 let root = Path::new("/other");
1715 let path = Path::new("/project/src/utils.ts");
1716 let rendered = format_display_path(path, root);
1717 assert!(rendered.contains("project"));
1718 assert!(rendered.ends_with("utils.ts"));
1719 assert!(!rendered.contains('\\'));
1720 }
1721
1722 #[test]
1723 fn format_display_path_normalizes_backslashes_to_forward_slashes() {
1724 let root = Path::new("/project");
1725 let path = Path::new("/project/src/sub\\file.ts");
1726 let rendered = format_display_path(path, root);
1727 assert!(
1728 !rendered.contains('\\'),
1729 "backslashes must be normalized: {rendered}"
1730 );
1731 }
1732
1733 #[test]
1734 fn format_display_path_handles_brackets_verbatim() {
1735 let root = Path::new("/project");
1736 let path = Path::new("/project/app/[slug]/page.tsx");
1737 assert_eq!(format_display_path(path, root), "app/[slug]/page.tsx");
1738 }
1739
1740 #[test]
1741 fn format_display_path_path_equals_root_returns_empty() {
1742 let root = Path::new("/project");
1743 let path = Path::new("/project");
1744 assert_eq!(format_display_path(path, root), "");
1745 }
1746
1747 #[test]
1748 fn format_display_path_basename_only_when_path_is_at_root() {
1749 let root = Path::new("/project");
1750 let path = Path::new("/project/Cargo.toml");
1751 assert_eq!(format_display_path(path, root), "Cargo.toml");
1752 }
1753
1754 #[test]
1755 fn relative_uri_produces_forward_slash_path() {
1756 let root = PathBuf::from("/project");
1757 let path = root.join("src").join("utils.ts");
1758 let uri = relative_uri(&path, &root);
1759 assert_eq!(uri, "src/utils.ts");
1760 }
1761
1762 #[test]
1763 fn relative_uri_encodes_brackets() {
1764 let root = PathBuf::from("/project");
1765 let path = root.join("src/app/[...slug]/page.tsx");
1766 let uri = relative_uri(&path, &root);
1767 assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
1768 }
1769
1770 #[test]
1771 fn relative_uri_encodes_nested_dynamic_routes() {
1772 let root = PathBuf::from("/project");
1773 let path = root.join("src/app/[slug]/[id]/page.tsx");
1774 let uri = relative_uri(&path, &root);
1775 assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
1776 }
1777
1778 #[test]
1779 fn relative_uri_no_common_prefix_returns_full() {
1780 let root = PathBuf::from("/other");
1781 let path = PathBuf::from("/project/src/utils.ts");
1782 let uri = relative_uri(&path, &root);
1783 assert!(uri.contains("project"));
1784 assert!(uri.contains("utils.ts"));
1785 }
1786
1787 #[test]
1788 fn severity_error_maps_to_level_error() {
1789 assert!(matches!(severity_to_level(Severity::Error), Level::Error));
1790 }
1791
1792 #[test]
1793 fn severity_warn_maps_to_level_warn() {
1794 assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
1795 }
1796
1797 #[test]
1798 fn severity_off_maps_to_level_info() {
1799 assert!(matches!(severity_to_level(Severity::Off), Level::Info));
1800 }
1801
1802 #[test]
1803 fn normalize_uri_single_bracket_pair() {
1804 assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
1805 }
1806
1807 #[test]
1808 fn normalize_uri_catch_all_route() {
1809 assert_eq!(
1810 normalize_uri("app/[...slug]/page.tsx"),
1811 "app/%5B...slug%5D/page.tsx"
1812 );
1813 }
1814
1815 #[test]
1816 fn normalize_uri_optional_catch_all_route() {
1817 assert_eq!(
1818 normalize_uri("app/[[...slug]]/page.tsx"),
1819 "app/%5B%5B...slug%5D%5D/page.tsx"
1820 );
1821 }
1822
1823 #[test]
1824 fn normalize_uri_multiple_dynamic_segments() {
1825 assert_eq!(
1826 normalize_uri("app/[lang]/posts/[id]"),
1827 "app/%5Blang%5D/posts/%5Bid%5D"
1828 );
1829 }
1830
1831 #[test]
1832 fn normalize_uri_no_special_chars() {
1833 let plain = "src/components/Button.tsx";
1834 assert_eq!(normalize_uri(plain), plain);
1835 }
1836
1837 #[test]
1838 fn normalize_uri_only_backslashes() {
1839 assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
1840 }
1841
1842 #[test]
1843 fn relative_path_identical_paths_returns_empty() {
1844 let root = Path::new("/project");
1845 assert_eq!(relative_path(root, root), Path::new(""));
1846 }
1847
1848 #[test]
1849 fn relative_path_partial_name_match_not_stripped() {
1850 let root = Path::new("/project");
1851 let path = Path::new("/project-two/src/a.ts");
1852 assert_eq!(relative_path(path, root), path);
1853 }
1854
1855 #[test]
1856 fn relative_uri_combines_stripping_and_encoding() {
1857 let root = PathBuf::from("/project");
1858 let path = root.join("src/app/[slug]/page.tsx");
1859 let uri = relative_uri(&path, &root);
1860 assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
1861 assert!(!uri.starts_with('/'));
1862 }
1863
1864 #[test]
1865 fn relative_uri_at_root_file() {
1866 let root = PathBuf::from("/project");
1867 let path = root.join("index.ts");
1868 assert_eq!(relative_uri(&path, &root), "index.ts");
1869 }
1870
1871 #[test]
1872 fn severity_to_level_is_const_evaluable() {
1873 const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
1874 const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
1875 const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
1876 assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
1877 assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
1878 assert!(matches!(LEVEL_FROM_OFF, Level::Info));
1879 }
1880
1881 #[test]
1882 fn level_is_copy() {
1883 let level = severity_to_level(Severity::Error);
1884 let copy = level;
1885 assert!(matches!(level, Level::Error));
1886 assert!(matches!(copy, Level::Error));
1887 }
1888
1889 #[test]
1890 fn print_results_rejects_badge_for_dead_code_reports() {
1891 let root = Path::new("/project");
1892 let rules = RulesConfig::default();
1893 let ctx = test_context(root, &rules);
1894
1895 let code = print_results(&AnalysisResults::default(), &ctx, OutputFormat::Badge, None);
1896
1897 assert_eq!(code, ExitCode::from(2));
1898 }
1899
1900 #[test]
1901 fn print_duplication_report_rejects_badge_format() {
1902 let root = Path::new("/project");
1903 let rules = RulesConfig::default();
1904 let ctx = test_context(root, &rules);
1905
1906 let code =
1907 print_duplication_report(&DuplicationReport::default(), &ctx, OutputFormat::Badge);
1908
1909 assert_eq!(code, ExitCode::from(2));
1910 }
1911
1912 #[test]
1913 fn elide_common_prefix_shared_dir() {
1914 assert_eq!(
1915 elide_common_prefix("src/components/A.tsx", "src/components/B.tsx"),
1916 "B.tsx"
1917 );
1918 }
1919
1920 #[test]
1921 fn elide_common_prefix_partial_shared() {
1922 assert_eq!(
1923 elide_common_prefix("src/components/A.tsx", "src/utils/B.tsx"),
1924 "utils/B.tsx"
1925 );
1926 }
1927
1928 #[test]
1929 fn elide_common_prefix_no_shared() {
1930 assert_eq!(
1931 elide_common_prefix("pkg-a/src/A.tsx", "pkg-b/src/B.tsx"),
1932 "pkg-b/src/B.tsx"
1933 );
1934 }
1935
1936 #[test]
1937 fn elide_common_prefix_identical_files() {
1938 assert_eq!(elide_common_prefix("a/b/x.ts", "a/b/y.ts"), "y.ts");
1939 }
1940
1941 #[test]
1942 fn elide_common_prefix_no_dirs() {
1943 assert_eq!(elide_common_prefix("foo.ts", "bar.ts"), "bar.ts");
1944 }
1945
1946 #[test]
1947 fn elide_common_prefix_deep_monorepo() {
1948 assert_eq!(
1949 elide_common_prefix(
1950 "packages/rap/src/rap/components/SearchSelect/SearchSelect.tsx",
1951 "packages/rap/src/rap/components/SearchSelect/SearchSelectItem.tsx"
1952 ),
1953 "SearchSelectItem.tsx"
1954 );
1955 }
1956
1957 #[test]
1958 fn split_dir_filename_with_dir() {
1959 let (dir, file) = split_dir_filename("src/utils/index.ts");
1960 assert_eq!(dir, "src/utils/");
1961 assert_eq!(file, "index.ts");
1962 }
1963
1964 #[test]
1965 fn split_dir_filename_no_dir() {
1966 let (dir, file) = split_dir_filename("file.ts");
1967 assert_eq!(dir, "");
1968 assert_eq!(file, "file.ts");
1969 }
1970
1971 #[test]
1972 fn split_dir_filename_deeply_nested() {
1973 let (dir, file) = split_dir_filename("a/b/c/d/e.ts");
1974 assert_eq!(dir, "a/b/c/d/");
1975 assert_eq!(file, "e.ts");
1976 }
1977
1978 #[test]
1979 fn split_dir_filename_trailing_slash() {
1980 let (dir, file) = split_dir_filename("src/");
1981 assert_eq!(dir, "src/");
1982 assert_eq!(file, "");
1983 }
1984
1985 #[test]
1986 fn split_dir_filename_empty() {
1987 let (dir, file) = split_dir_filename("");
1988 assert_eq!(dir, "");
1989 assert_eq!(file, "");
1990 }
1991
1992 #[test]
1993 fn plural_zero_is_plural() {
1994 assert_eq!(plural(0), "s");
1995 }
1996
1997 #[test]
1998 fn plural_one_is_singular() {
1999 assert_eq!(plural(1), "");
2000 }
2001
2002 #[test]
2003 fn plural_two_is_plural() {
2004 assert_eq!(plural(2), "s");
2005 }
2006
2007 #[test]
2008 fn plural_large_number() {
2009 assert_eq!(plural(999), "s");
2010 }
2011
2012 #[test]
2013 fn elide_common_prefix_empty_base() {
2014 assert_eq!(elide_common_prefix("", "src/foo.ts"), "src/foo.ts");
2015 }
2016
2017 #[test]
2018 fn elide_common_prefix_empty_target() {
2019 assert_eq!(elide_common_prefix("src/foo.ts", ""), "");
2020 }
2021
2022 #[test]
2023 fn elide_common_prefix_both_empty() {
2024 assert_eq!(elide_common_prefix("", ""), "");
2025 }
2026
2027 #[test]
2028 fn elide_common_prefix_same_file_different_extension() {
2029 assert_eq!(
2030 elide_common_prefix("src/utils.ts", "src/utils.js"),
2031 "utils.js"
2032 );
2033 }
2034
2035 #[test]
2036 fn elide_common_prefix_partial_filename_match_not_stripped() {
2037 assert_eq!(
2038 elide_common_prefix("src/App.tsx", "src/AppUtils.tsx"),
2039 "AppUtils.tsx"
2040 );
2041 }
2042
2043 #[test]
2044 fn elide_common_prefix_identical_paths() {
2045 assert_eq!(elide_common_prefix("src/foo.ts", "src/foo.ts"), "foo.ts");
2046 }
2047
2048 #[test]
2049 fn split_dir_filename_single_slash() {
2050 let (dir, file) = split_dir_filename("/file.ts");
2051 assert_eq!(dir, "/");
2052 assert_eq!(file, "file.ts");
2053 }
2054
2055 #[test]
2056 fn emit_json_returns_success_for_valid_value() {
2057 let value = serde_json::json!({"key": "value"});
2058 let code = emit_json(&value, "test");
2059 assert_eq!(code, ExitCode::SUCCESS);
2060 }
2061
2062 mod proptests {
2063 use super::*;
2064 use proptest::prelude::*;
2065
2066 proptest! {
2067 #[test]
2069 fn split_dir_filename_reconstructs_path(path in "[a-zA-Z0-9_./\\-]{0,100}") {
2070 let (dir, file) = split_dir_filename(&path);
2071 let reconstructed = format!("{dir}{file}");
2072 prop_assert_eq!(
2073 reconstructed, path,
2074 "dir+file should reconstruct the original path"
2075 );
2076 }
2077
2078 #[test]
2080 fn plural_returns_empty_or_s(n: usize) {
2081 let result = plural(n);
2082 prop_assert!(
2083 result.is_empty() || result == "s",
2084 "plural should return \"\" or \"s\", got {:?}",
2085 result
2086 );
2087 }
2088
2089 #[test]
2091 fn plural_singular_only_for_one(n: usize) {
2092 let result = plural(n);
2093 if n == 1 {
2094 prop_assert_eq!(result, "", "plural(1) should be empty");
2095 } else {
2096 prop_assert_eq!(result, "s", "plural({}) should be \"s\"", n);
2097 }
2098 }
2099
2100 #[test]
2102 fn normalize_uri_no_backslashes(path in "[a-zA-Z0-9_.\\\\/ \\[\\]%-]{0,100}") {
2103 let result = normalize_uri(&path);
2104 prop_assert!(
2105 !result.contains('\\'),
2106 "Result should not contain backslashes: {result}"
2107 );
2108 }
2109
2110 #[test]
2112 fn normalize_uri_encodes_all_brackets(path in "[a-zA-Z0-9_./\\[\\]%-]{0,80}") {
2113 let result = normalize_uri(&path);
2114 prop_assert!(
2115 !result.contains('[') && !result.contains(']'),
2116 "Result should not contain raw brackets: {result}"
2117 );
2118 }
2119
2120 #[test]
2122 fn elide_common_prefix_returns_suffix_of_target(
2123 base in "[a-zA-Z0-9_./]{0,50}",
2124 target in "[a-zA-Z0-9_./]{0,50}",
2125 ) {
2126 let result = elide_common_prefix(&base, &target);
2127 prop_assert!(
2128 target.ends_with(result),
2129 "Result {:?} should be a suffix of target {:?}",
2130 result, target
2131 );
2132 }
2133
2134 #[test]
2136 fn relative_path_never_panics(
2137 root in "/[a-zA-Z0-9_/]{0,30}",
2138 suffix in "[a-zA-Z0-9_./]{0,30}",
2139 ) {
2140 let root_path = Path::new(&root);
2141 let full = PathBuf::from(format!("{root}/{suffix}"));
2142 let _ = relative_path(&full, root_path);
2143 }
2144 }
2145 }
2146}