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