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