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