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
1370pub(crate) fn print_performance(
1373 timings: &PipelineTimings,
1374 format: OutputFormat,
1375 json_style: crate::json_style::JsonStyle,
1376) {
1377 match format {
1378 OutputFormat::Json => match json_style.serialize(timings) {
1379 Ok(json) => eprintln!("{json}"),
1380 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
1381 },
1382 _ => human::print_performance_human(timings),
1383 }
1384}
1385
1386pub(crate) fn print_health_performance(
1389 timings: &fallow_output::HealthTimings,
1390 format: OutputFormat,
1391 json_style: crate::json_style::JsonStyle,
1392) {
1393 match format {
1394 OutputFormat::Json => match json_style.serialize(timings) {
1395 Ok(json) => eprintln!("{json}"),
1396 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
1397 },
1398 _ => human::print_health_performance_human(timings),
1399 }
1400}
1401
1402#[allow(
1403 unused_imports,
1404 reason = "target-dependent: used in lib, unused in bin"
1405)]
1406pub use fallow_api::build_compact_lines;
1407#[allow(
1408 unused_imports,
1409 reason = "target-dependent: used in lib, unused in bin"
1410)]
1411pub use fallow_api::build_duplication_markdown;
1412#[allow(
1413 unused_imports,
1414 reason = "target-dependent: used in lib, unused in bin"
1415)]
1416pub use fallow_api::build_health_markdown;
1417#[allow(
1418 unused_imports,
1419 reason = "target-dependent: used in lib, unused in bin"
1420)]
1421pub use fallow_api::build_markdown;
1422#[allow(
1423 clippy::redundant_pub_crate,
1424 reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
1425)]
1426pub(crate) use json::api_check_json_payload_with_config_fixable;
1427#[allow(
1428 clippy::redundant_pub_crate,
1429 reason = "target-dependent: report is public in lib, private in bin, but these adapters remain crate-internal"
1430)]
1431pub(crate) use json::{build_baseline_deltas_output, check_json_extras};
1432#[allow(
1433 unused_imports,
1434 reason = "target-dependent: used in lib, unused in bin"
1435)]
1436#[allow(
1437 clippy::redundant_pub_crate,
1438 reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
1439)]
1440pub(crate) use sarif::api_health_sarif_document;
1441#[allow(
1442 unused_imports,
1443 reason = "target-dependent: used in lib, unused in bin"
1444)]
1445#[allow(
1446 clippy::redundant_pub_crate,
1447 reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
1448)]
1449pub(crate) use sarif::api_sarif_document;
1450
1451#[cfg(test)]
1452mod tests {
1453 use super::*;
1454 use std::path::{Path, PathBuf};
1455
1456 #[test]
1457 fn format_bytes_pivots_at_power_of_1024() {
1458 assert_eq!(format_bytes(0), "0 B");
1459 assert_eq!(format_bytes(1023), "1023 B");
1460 assert_eq!(format_bytes(1024), "1 KiB");
1461 assert_eq!(format_bytes(2048), "2 KiB");
1462 assert_eq!(format_bytes(1_048_576), "1.0 MiB");
1463 assert_eq!(format_bytes(10_485_760), "10.0 MiB");
1464 assert_eq!(format_bytes(1_073_741_824), "1.0 GiB");
1465 }
1466
1467 fn test_context<'a>(root: &'a Path, rules: &'a RulesConfig) -> ReportContext<'a> {
1468 ReportContext {
1469 baseline_staleness: None,
1470 gate_outcomes: None,
1471 failed_parse_files: 0,
1472 root,
1473 rules,
1474 workspace_diagnostics: &[],
1475 elapsed: Duration::default(),
1476 quiet: true,
1477 explain: false,
1478 type_aware: None,
1479 type_aware_scope: None,
1480 group_by: None,
1481 top: None,
1482 summary: false,
1483 summary_heading: false,
1484 show_explain_tip: false,
1485 baseline_matched: None,
1486 config_fixable: false,
1487 skip_score_and_trend: false,
1488 css_requested: false,
1489 json_style: crate::json_style::JsonStyle::Compact,
1490 include_fragments: true,
1491 }
1492 }
1493
1494 #[test]
1495 fn normalize_uri_forward_slashes_unchanged() {
1496 assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
1497 }
1498
1499 #[test]
1500 fn normalize_uri_backslashes_replaced() {
1501 assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
1502 }
1503
1504 #[test]
1505 fn normalize_uri_mixed_slashes() {
1506 assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
1507 }
1508
1509 #[test]
1510 fn normalize_uri_path_with_spaces() {
1511 assert_eq!(
1512 normalize_uri("src\\my folder\\file.ts"),
1513 "src/my folder/file.ts"
1514 );
1515 }
1516
1517 #[test]
1518 fn normalize_uri_empty_string() {
1519 assert_eq!(normalize_uri(""), "");
1520 }
1521
1522 #[test]
1523 fn relative_path_strips_root_prefix() {
1524 let root = Path::new("/project");
1525 let path = Path::new("/project/src/utils.ts");
1526 assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
1527 }
1528
1529 #[test]
1530 fn relative_path_returns_full_path_when_no_prefix() {
1531 let root = Path::new("/other");
1532 let path = Path::new("/project/src/utils.ts");
1533 assert_eq!(relative_path(path, root), path);
1534 }
1535
1536 #[test]
1537 fn relative_path_at_root_returns_empty_or_file() {
1538 let root = Path::new("/project");
1539 let path = Path::new("/project/file.ts");
1540 assert_eq!(relative_path(path, root), Path::new("file.ts"));
1541 }
1542
1543 #[test]
1544 fn relative_path_deeply_nested() {
1545 let root = Path::new("/project");
1546 let path = Path::new("/project/packages/ui/src/components/Button.tsx");
1547 assert_eq!(
1548 relative_path(path, root),
1549 Path::new("packages/ui/src/components/Button.tsx")
1550 );
1551 }
1552
1553 #[test]
1554 fn format_display_path_returns_workspace_relative() {
1555 let root = Path::new("/project");
1556 let path = Path::new("/project/apps/server/src/index.ts");
1557 assert_eq!(format_display_path(path, root), "apps/server/src/index.ts");
1558 }
1559
1560 #[test]
1561 fn format_display_path_collides_in_nx_layout_renders_full_relative() {
1562 let root = Path::new("/project");
1563 let server = Path::new("/project/apps/server/src/index.ts");
1564 let client = Path::new("/project/apps/client/src/index.ts");
1565 assert_eq!(
1566 format_display_path(server, root),
1567 "apps/server/src/index.ts"
1568 );
1569 assert_eq!(
1570 format_display_path(client, root),
1571 "apps/client/src/index.ts"
1572 );
1573 }
1574
1575 #[test]
1576 fn format_display_path_angular_component_renders_parent_directory() {
1577 let root = Path::new("/project");
1578 let path = Path::new(
1579 "/project/apps/admin/src/app/payments/payment-list/payment-list.component.html",
1580 );
1581 assert_eq!(
1582 format_display_path(path, root),
1583 "apps/admin/src/app/payments/payment-list/payment-list.component.html"
1584 );
1585 }
1586
1587 #[test]
1588 fn format_display_path_falls_back_to_full_path_when_root_does_not_prefix() {
1589 let root = Path::new("/other");
1590 let path = Path::new("/project/src/utils.ts");
1591 let rendered = format_display_path(path, root);
1592 assert!(rendered.contains("project"));
1593 assert!(rendered.ends_with("utils.ts"));
1594 assert!(!rendered.contains('\\'));
1595 }
1596
1597 #[test]
1598 fn format_display_path_normalizes_backslashes_to_forward_slashes() {
1599 let root = Path::new("/project");
1600 let path = Path::new("/project/src/sub\\file.ts");
1601 let rendered = format_display_path(path, root);
1602 assert!(
1603 !rendered.contains('\\'),
1604 "backslashes must be normalized: {rendered}"
1605 );
1606 }
1607
1608 #[test]
1609 fn format_display_path_handles_brackets_verbatim() {
1610 let root = Path::new("/project");
1611 let path = Path::new("/project/app/[slug]/page.tsx");
1612 assert_eq!(format_display_path(path, root), "app/[slug]/page.tsx");
1613 }
1614
1615 #[test]
1616 fn format_display_path_path_equals_root_returns_empty() {
1617 let root = Path::new("/project");
1618 let path = Path::new("/project");
1619 assert_eq!(format_display_path(path, root), "");
1620 }
1621
1622 #[test]
1623 fn format_display_path_basename_only_when_path_is_at_root() {
1624 let root = Path::new("/project");
1625 let path = Path::new("/project/Cargo.toml");
1626 assert_eq!(format_display_path(path, root), "Cargo.toml");
1627 }
1628
1629 #[test]
1630 fn relative_uri_produces_forward_slash_path() {
1631 let root = PathBuf::from("/project");
1632 let path = root.join("src").join("utils.ts");
1633 let uri = relative_uri(&path, &root);
1634 assert_eq!(uri, "src/utils.ts");
1635 }
1636
1637 #[test]
1638 fn relative_uri_encodes_brackets() {
1639 let root = PathBuf::from("/project");
1640 let path = root.join("src/app/[...slug]/page.tsx");
1641 let uri = relative_uri(&path, &root);
1642 assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
1643 }
1644
1645 #[test]
1646 fn relative_uri_encodes_nested_dynamic_routes() {
1647 let root = PathBuf::from("/project");
1648 let path = root.join("src/app/[slug]/[id]/page.tsx");
1649 let uri = relative_uri(&path, &root);
1650 assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
1651 }
1652
1653 #[test]
1654 fn relative_uri_no_common_prefix_returns_full() {
1655 let root = PathBuf::from("/other");
1656 let path = PathBuf::from("/project/src/utils.ts");
1657 let uri = relative_uri(&path, &root);
1658 assert!(uri.contains("project"));
1659 assert!(uri.contains("utils.ts"));
1660 }
1661
1662 #[test]
1663 fn severity_error_maps_to_level_error() {
1664 assert!(matches!(severity_to_level(Severity::Error), Level::Error));
1665 }
1666
1667 #[test]
1668 fn severity_warn_maps_to_level_warn() {
1669 assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
1670 }
1671
1672 #[test]
1673 fn severity_off_maps_to_level_info() {
1674 assert!(matches!(severity_to_level(Severity::Off), Level::Info));
1675 }
1676
1677 #[test]
1678 fn normalize_uri_single_bracket_pair() {
1679 assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
1680 }
1681
1682 #[test]
1683 fn normalize_uri_catch_all_route() {
1684 assert_eq!(
1685 normalize_uri("app/[...slug]/page.tsx"),
1686 "app/%5B...slug%5D/page.tsx"
1687 );
1688 }
1689
1690 #[test]
1691 fn normalize_uri_optional_catch_all_route() {
1692 assert_eq!(
1693 normalize_uri("app/[[...slug]]/page.tsx"),
1694 "app/%5B%5B...slug%5D%5D/page.tsx"
1695 );
1696 }
1697
1698 #[test]
1699 fn normalize_uri_multiple_dynamic_segments() {
1700 assert_eq!(
1701 normalize_uri("app/[lang]/posts/[id]"),
1702 "app/%5Blang%5D/posts/%5Bid%5D"
1703 );
1704 }
1705
1706 #[test]
1707 fn normalize_uri_no_special_chars() {
1708 let plain = "src/components/Button.tsx";
1709 assert_eq!(normalize_uri(plain), plain);
1710 }
1711
1712 #[test]
1713 fn normalize_uri_only_backslashes() {
1714 assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
1715 }
1716
1717 #[test]
1718 fn relative_path_identical_paths_returns_empty() {
1719 let root = Path::new("/project");
1720 assert_eq!(relative_path(root, root), Path::new(""));
1721 }
1722
1723 #[test]
1724 fn relative_path_partial_name_match_not_stripped() {
1725 let root = Path::new("/project");
1726 let path = Path::new("/project-two/src/a.ts");
1727 assert_eq!(relative_path(path, root), path);
1728 }
1729
1730 #[test]
1731 fn relative_uri_combines_stripping_and_encoding() {
1732 let root = PathBuf::from("/project");
1733 let path = root.join("src/app/[slug]/page.tsx");
1734 let uri = relative_uri(&path, &root);
1735 assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
1736 assert!(!uri.starts_with('/'));
1737 }
1738
1739 #[test]
1740 fn relative_uri_at_root_file() {
1741 let root = PathBuf::from("/project");
1742 let path = root.join("index.ts");
1743 assert_eq!(relative_uri(&path, &root), "index.ts");
1744 }
1745
1746 #[test]
1747 fn severity_to_level_is_const_evaluable() {
1748 const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
1749 const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
1750 const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
1751 assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
1752 assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
1753 assert!(matches!(LEVEL_FROM_OFF, Level::Info));
1754 }
1755
1756 #[test]
1757 fn level_is_copy() {
1758 let level = severity_to_level(Severity::Error);
1759 let copy = level;
1760 assert!(matches!(level, Level::Error));
1761 assert!(matches!(copy, Level::Error));
1762 }
1763
1764 #[test]
1765 fn print_results_rejects_badge_for_dead_code_reports() {
1766 let root = Path::new("/project");
1767 let rules = RulesConfig::default();
1768 let ctx = test_context(root, &rules);
1769
1770 let code = print_results(&AnalysisResults::default(), &ctx, OutputFormat::Badge, None);
1771
1772 assert_eq!(code, ExitCode::from(2));
1773 }
1774
1775 #[test]
1776 fn print_duplication_report_rejects_badge_format() {
1777 let root = Path::new("/project");
1778 let rules = RulesConfig::default();
1779 let ctx = test_context(root, &rules);
1780
1781 let code =
1782 print_duplication_report(&DuplicationReport::default(), &ctx, OutputFormat::Badge);
1783
1784 assert_eq!(code, ExitCode::from(2));
1785 }
1786
1787 #[test]
1788 fn elide_common_prefix_shared_dir() {
1789 assert_eq!(
1790 elide_common_prefix("src/components/A.tsx", "src/components/B.tsx"),
1791 "B.tsx"
1792 );
1793 }
1794
1795 #[test]
1796 fn elide_common_prefix_partial_shared() {
1797 assert_eq!(
1798 elide_common_prefix("src/components/A.tsx", "src/utils/B.tsx"),
1799 "utils/B.tsx"
1800 );
1801 }
1802
1803 #[test]
1804 fn elide_common_prefix_no_shared() {
1805 assert_eq!(
1806 elide_common_prefix("pkg-a/src/A.tsx", "pkg-b/src/B.tsx"),
1807 "pkg-b/src/B.tsx"
1808 );
1809 }
1810
1811 #[test]
1812 fn elide_common_prefix_identical_files() {
1813 assert_eq!(elide_common_prefix("a/b/x.ts", "a/b/y.ts"), "y.ts");
1814 }
1815
1816 #[test]
1817 fn elide_common_prefix_no_dirs() {
1818 assert_eq!(elide_common_prefix("foo.ts", "bar.ts"), "bar.ts");
1819 }
1820
1821 #[test]
1822 fn elide_common_prefix_deep_monorepo() {
1823 assert_eq!(
1824 elide_common_prefix(
1825 "packages/rap/src/rap/components/SearchSelect/SearchSelect.tsx",
1826 "packages/rap/src/rap/components/SearchSelect/SearchSelectItem.tsx"
1827 ),
1828 "SearchSelectItem.tsx"
1829 );
1830 }
1831
1832 #[test]
1833 fn split_dir_filename_with_dir() {
1834 let (dir, file) = split_dir_filename("src/utils/index.ts");
1835 assert_eq!(dir, "src/utils/");
1836 assert_eq!(file, "index.ts");
1837 }
1838
1839 #[test]
1840 fn split_dir_filename_no_dir() {
1841 let (dir, file) = split_dir_filename("file.ts");
1842 assert_eq!(dir, "");
1843 assert_eq!(file, "file.ts");
1844 }
1845
1846 #[test]
1847 fn split_dir_filename_deeply_nested() {
1848 let (dir, file) = split_dir_filename("a/b/c/d/e.ts");
1849 assert_eq!(dir, "a/b/c/d/");
1850 assert_eq!(file, "e.ts");
1851 }
1852
1853 #[test]
1854 fn split_dir_filename_trailing_slash() {
1855 let (dir, file) = split_dir_filename("src/");
1856 assert_eq!(dir, "src/");
1857 assert_eq!(file, "");
1858 }
1859
1860 #[test]
1861 fn split_dir_filename_empty() {
1862 let (dir, file) = split_dir_filename("");
1863 assert_eq!(dir, "");
1864 assert_eq!(file, "");
1865 }
1866
1867 #[test]
1868 fn plural_zero_is_plural() {
1869 assert_eq!(plural(0), "s");
1870 }
1871
1872 #[test]
1873 fn plural_one_is_singular() {
1874 assert_eq!(plural(1), "");
1875 }
1876
1877 #[test]
1878 fn plural_two_is_plural() {
1879 assert_eq!(plural(2), "s");
1880 }
1881
1882 #[test]
1883 fn plural_large_number() {
1884 assert_eq!(plural(999), "s");
1885 }
1886
1887 #[test]
1888 fn elide_common_prefix_empty_base() {
1889 assert_eq!(elide_common_prefix("", "src/foo.ts"), "src/foo.ts");
1890 }
1891
1892 #[test]
1893 fn elide_common_prefix_empty_target() {
1894 assert_eq!(elide_common_prefix("src/foo.ts", ""), "");
1895 }
1896
1897 #[test]
1898 fn elide_common_prefix_both_empty() {
1899 assert_eq!(elide_common_prefix("", ""), "");
1900 }
1901
1902 #[test]
1903 fn elide_common_prefix_same_file_different_extension() {
1904 assert_eq!(
1905 elide_common_prefix("src/utils.ts", "src/utils.js"),
1906 "utils.js"
1907 );
1908 }
1909
1910 #[test]
1911 fn elide_common_prefix_partial_filename_match_not_stripped() {
1912 assert_eq!(
1913 elide_common_prefix("src/App.tsx", "src/AppUtils.tsx"),
1914 "AppUtils.tsx"
1915 );
1916 }
1917
1918 #[test]
1919 fn elide_common_prefix_identical_paths() {
1920 assert_eq!(elide_common_prefix("src/foo.ts", "src/foo.ts"), "foo.ts");
1921 }
1922
1923 #[test]
1924 fn split_dir_filename_single_slash() {
1925 let (dir, file) = split_dir_filename("/file.ts");
1926 assert_eq!(dir, "/");
1927 assert_eq!(file, "file.ts");
1928 }
1929
1930 #[test]
1931 fn emit_json_returns_success_for_valid_value() {
1932 let value = serde_json::json!({"key": "value"});
1933 let code = emit_json(&value, "test");
1934 assert_eq!(code, ExitCode::SUCCESS);
1935 }
1936
1937 mod proptests {
1938 use super::*;
1939 use proptest::prelude::*;
1940
1941 proptest! {
1942 #[test]
1944 fn split_dir_filename_reconstructs_path(path in "[a-zA-Z0-9_./\\-]{0,100}") {
1945 let (dir, file) = split_dir_filename(&path);
1946 let reconstructed = format!("{dir}{file}");
1947 prop_assert_eq!(
1948 reconstructed, path,
1949 "dir+file should reconstruct the original path"
1950 );
1951 }
1952
1953 #[test]
1955 fn plural_returns_empty_or_s(n: usize) {
1956 let result = plural(n);
1957 prop_assert!(
1958 result.is_empty() || result == "s",
1959 "plural should return \"\" or \"s\", got {:?}",
1960 result
1961 );
1962 }
1963
1964 #[test]
1966 fn plural_singular_only_for_one(n: usize) {
1967 let result = plural(n);
1968 if n == 1 {
1969 prop_assert_eq!(result, "", "plural(1) should be empty");
1970 } else {
1971 prop_assert_eq!(result, "s", "plural({}) should be \"s\"", n);
1972 }
1973 }
1974
1975 #[test]
1977 fn normalize_uri_no_backslashes(path in "[a-zA-Z0-9_.\\\\/ \\[\\]%-]{0,100}") {
1978 let result = normalize_uri(&path);
1979 prop_assert!(
1980 !result.contains('\\'),
1981 "Result should not contain backslashes: {result}"
1982 );
1983 }
1984
1985 #[test]
1987 fn normalize_uri_encodes_all_brackets(path in "[a-zA-Z0-9_./\\[\\]%-]{0,80}") {
1988 let result = normalize_uri(&path);
1989 prop_assert!(
1990 !result.contains('[') && !result.contains(']'),
1991 "Result should not contain raw brackets: {result}"
1992 );
1993 }
1994
1995 #[test]
1997 fn elide_common_prefix_returns_suffix_of_target(
1998 base in "[a-zA-Z0-9_./]{0,50}",
1999 target in "[a-zA-Z0-9_./]{0,50}",
2000 ) {
2001 let result = elide_common_prefix(&base, &target);
2002 prop_assert!(
2003 target.ends_with(result),
2004 "Result {:?} should be a suffix of target {:?}",
2005 result, target
2006 );
2007 }
2008
2009 #[test]
2011 fn relative_path_never_panics(
2012 root in "/[a-zA-Z0-9_/]{0,30}",
2013 suffix in "[a-zA-Z0-9_./]{0,30}",
2014 ) {
2015 let root_path = Path::new(&root);
2016 let full = PathBuf::from(format!("{root}/{suffix}"));
2017 let _ = relative_path(&full, root_path);
2018 }
2019 }
2020 }
2021}