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