1mod badge;
2pub mod ci;
3pub(crate) mod codeclimate;
4mod compact;
5pub mod dupes_grouping;
6pub(crate) mod gate_outcome_text;
7pub mod github;
8pub mod github_annotations;
9pub mod github_summary;
10pub mod grouping;
11mod human;
12mod json;
13mod markdown;
14pub(crate) mod sarif;
15mod shared;
16pub(crate) mod sink;
17mod status;
18pub(crate) mod suggestions;
19#[cfg(test)]
20pub(crate) mod test_helpers;
21
22use std::path::Path;
23use std::process::ExitCode;
24use std::time::Duration;
25
26use fallow_api::DuplicationGrouping;
27use fallow_config::{OutputFormat, RulesConfig, Severity};
28use fallow_types::duplicates::DuplicationReport;
29use fallow_types::results::AnalysisResults;
30use fallow_types::semantic::SemanticSymbolImpact;
31use fallow_types::trace::{
32 CloneTrace, DependencyTrace, ExportTrace, FileTrace, ImpactClosureTrace, PipelineTimings,
33};
34
35use crate::report::sink::outln;
36
37#[allow(
38 unused_imports,
39 reason = "used by binary crate modules (combined.rs, audit.rs)"
40)]
41pub use fallow_output::strip_root_prefix;
42pub use grouping::OwnershipResolver;
43pub(crate) use human::dupes::MAX_CLONE_GROUPS;
44pub(crate) use human::health::{render_health_score, render_health_trend};
45pub(crate) use status::{
46 HumanStatus, line as human_status_line, semantic_status, type_aware_meta_status,
47};
48
49pub(crate) struct WalkthroughHumanRender {
55 pub(crate) header: Vec<String>,
57 pub(crate) body: Vec<String>,
59 pub(crate) status: String,
61}
62
63#[must_use]
68pub(crate) fn walkthrough_viewed_files(
69 guide: &fallow_output::StandardWalkthroughGuide,
70 viewed: &crate::walkthrough_state::ViewedState,
71) -> Vec<String> {
72 human::walkthrough::viewed_files_for(guide, viewed)
73}
74
75#[must_use]
79pub(crate) fn build_walkthrough_human(
80 guide: &fallow_output::StandardWalkthroughGuide,
81 viewed: &crate::walkthrough_state::ViewedState,
82 show_cleared: bool,
83) -> WalkthroughHumanRender {
84 let input = human::walkthrough::WalkthroughHumanInput {
85 guide,
86 viewed,
87 show_cleared,
88 };
89 WalkthroughHumanRender {
90 header: human::walkthrough::build_focus_header(guide, viewed),
91 body: human::walkthrough::build_walkthrough_human_lines(&input),
92 status: human::walkthrough::build_status_line(guide, viewed),
93 }
94}
95
96pub(crate) struct ReportContext<'a> {
101 pub(crate) root: &'a Path,
102 pub(crate) rules: &'a RulesConfig,
103 pub(crate) workspace_diagnostics: &'a [fallow_config::WorkspaceDiagnostic],
105 pub(crate) elapsed: Duration,
106 pub(crate) quiet: bool,
107 pub(crate) explain: bool,
108 pub(crate) type_aware: Option<&'a fallow_types::envelope::TypeAwareMeta>,
110 pub(crate) type_aware_scope: Option<&'static str>,
113 pub(crate) group_by: Option<OwnershipResolver>,
115 pub(crate) top: Option<usize>,
117 pub(crate) summary: bool,
119 pub(crate) summary_heading: bool,
123 pub(crate) show_explain_tip: bool,
125 pub(crate) baseline_matched: Option<(usize, usize)>,
127 pub(crate) baseline_staleness: Option<fallow_output::BaselineStaleness>,
132 pub(crate) gate_outcomes: Option<fallow_output::GateOutcomes>,
135 pub(crate) config_fixable: bool,
140 pub(crate) skip_score_and_trend: bool,
145 pub(crate) css_requested: bool,
149 pub(crate) json_style: crate::json_style::JsonStyle,
151 pub(crate) include_fragments: bool,
155}
156
157#[must_use]
159pub(crate) fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
160 path.strip_prefix(root).unwrap_or(path)
161}
162
163#[must_use]
173pub(crate) fn format_display_path(path: &Path, root: &Path) -> String {
174 relative_path(path, root)
175 .display()
176 .to_string()
177 .replace('\\', "/")
178}
179
180#[must_use]
183pub(crate) fn split_dir_filename(path: &str) -> (&str, &str) {
184 path.rfind('/')
185 .map_or(("", path), |pos| (&path[..=pos], &path[pos + 1..]))
186}
187
188#[must_use]
190pub(crate) const fn plural(n: usize) -> &'static str {
191 if n == 1 { "" } else { "s" }
192}
193
194#[expect(
197 clippy::cast_precision_loss,
198 reason = "reported byte counts are well under the f64 precision loss range"
199)]
200#[must_use]
201pub(crate) fn format_bytes(bytes: u64) -> String {
202 const KIB: u64 = 1024;
203 const MIB: u64 = KIB * 1024;
204 const GIB: u64 = MIB * 1024;
205 if bytes >= GIB {
206 format!("{:.1} GiB", bytes as f64 / GIB as f64)
207 } else if bytes >= MIB {
208 format!("{:.1} MiB", bytes as f64 / MIB as f64)
209 } else if bytes >= KIB {
210 format!("{:.0} KiB", bytes as f64 / KIB as f64)
211 } else {
212 format!("{bytes} B")
213 }
214}
215
216#[must_use]
221pub(crate) fn emit_json(value: &serde_json::Value, kind: &str) -> ExitCode {
222 match serde_json::to_string_pretty(value) {
223 Ok(json) => {
224 outln!("{json}");
225 ExitCode::SUCCESS
226 }
227 Err(e) => {
228 eprintln!("Error: failed to serialize {kind} output: {e}");
229 ExitCode::from(2)
230 }
231 }
232}
233
234#[must_use]
236pub(crate) fn emit_report_json(
237 value: &serde_json::Value,
238 kind: &str,
239 style: crate::json_style::JsonStyle,
240) -> ExitCode {
241 match style.serialize(value) {
242 Ok(json) => {
243 outln!("{json}");
244 ExitCode::SUCCESS
245 }
246 Err(e) => {
247 eprintln!("Error: failed to serialize {kind} output: {e}");
248 ExitCode::from(2)
249 }
250 }
251}
252
253pub(crate) struct CheckJsonRenderInput<'a> {
254 pub(crate) results: &'a AnalysisResults,
255 pub(crate) root: &'a Path,
256 pub(crate) elapsed: Duration,
257 pub(crate) type_aware: Option<&'a fallow_types::envelope::TypeAwareMeta>,
258 pub(crate) regression: Option<&'a crate::regression::RegressionOutcome>,
259 pub(crate) baseline_matched: Option<(usize, usize)>,
260 pub(crate) baseline_staleness: Option<fallow_output::BaselineStaleness>,
261 pub(crate) gate_outcomes: Option<fallow_output::GateOutcomes>,
262 pub(crate) config_fixable: bool,
263 pub(crate) workspace_diagnostics: &'a [fallow_config::WorkspaceDiagnostic],
264 pub(crate) json_style: crate::json_style::JsonStyle,
265}
266
267pub(crate) fn render_check_json(
268 input: &CheckJsonRenderInput<'_>,
269) -> Result<String, serde_json::Error> {
270 json::render_json(&json::PrintJsonInput {
271 results: input.results,
272 root: input.root,
273 elapsed: input.elapsed,
274 explain: false,
275 type_aware: input.type_aware,
276 regression: input.regression,
277 baseline_matched: input.baseline_matched,
278 baseline_staleness: input.baseline_staleness,
279 gate_outcomes: input.gate_outcomes.clone(),
280 config_fixable: input.config_fixable,
281 workspace_diagnostics: input.workspace_diagnostics,
282 json_style: input.json_style,
283 })
284}
285
286#[must_use]
292pub(crate) fn elide_common_prefix<'a>(base: &str, target: &'a str) -> &'a str {
293 let mut last_sep = 0;
294 for (i, (a, b)) in base.bytes().zip(target.bytes()).enumerate() {
295 if a != b {
296 break;
297 }
298 if a == b'/' {
299 last_sep = i + 1;
300 }
301 }
302 if last_sep > 0 && last_sep <= target.len() {
303 &target[last_sep..]
304 } else {
305 target
306 }
307}
308
309#[cfg(test)]
311fn relative_uri(path: &Path, root: &Path) -> String {
312 normalize_uri(&relative_path(path, root).display().to_string())
313}
314
315#[must_use]
320pub(crate) fn normalize_uri(path_str: &str) -> String {
321 fallow_output::normalize_uri(path_str)
322}
323
324#[derive(Clone, Copy, Debug)]
326pub enum Level {
327 Warn,
328 Info,
329 Error,
330}
331
332#[must_use]
333pub(crate) const fn severity_to_level(s: Severity) -> Level {
334 match s {
335 Severity::Error => Level::Error,
336 Severity::Warn => Level::Warn,
337 Severity::Off => Level::Info,
338 }
339}
340
341#[must_use]
347pub(crate) fn print_results(
348 results: &AnalysisResults,
349 ctx: &ReportContext<'_>,
350 output: OutputFormat,
351 regression: Option<&crate::regression::RegressionOutcome>,
352) -> ExitCode {
353 if let Some(ref resolver) = ctx.group_by {
354 let groups = grouping::group_analysis_results(results, ctx.root, resolver);
355 return print_grouped_results(&groups, results, ctx, output, resolver);
356 }
357
358 match output {
359 OutputFormat::Human => {
360 if ctx.summary {
361 human::check::print_check_summary(
362 results,
363 ctx.rules,
364 ctx.elapsed,
365 ctx.quiet,
366 ctx.summary_heading,
367 );
368 } else {
369 human::print_human(&human::PrintHumanInput {
370 results,
371 root: ctx.root,
372 rules: ctx.rules,
373 elapsed: ctx.elapsed,
374 quiet: ctx.quiet,
375 top: ctx.top,
376 show_explain_tip: ctx.show_explain_tip,
377 explain: ctx.explain,
378 });
379 }
380 ExitCode::SUCCESS
381 }
382 OutputFormat::Json => json::print_json(&json::PrintJsonInput {
383 results,
384 root: ctx.root,
385 elapsed: ctx.elapsed,
386 explain: ctx.explain,
387 type_aware: ctx.type_aware,
388 regression,
389 baseline_matched: ctx.baseline_matched,
390 baseline_staleness: ctx.baseline_staleness,
391 gate_outcomes: ctx.gate_outcomes.clone(),
392 config_fixable: ctx.config_fixable,
393 workspace_diagnostics: ctx.workspace_diagnostics,
394 json_style: ctx.json_style,
395 }),
396 OutputFormat::Compact => {
397 compact::print_compact(results, ctx.root);
398 compact::print_type_aware_compact(ctx.type_aware, ctx.type_aware_scope);
399 ExitCode::SUCCESS
400 }
401 OutputFormat::Sarif => sarif::print_sarif(results, ctx.root, ctx.rules, ctx.type_aware),
402 OutputFormat::Markdown => {
403 markdown::print_markdown(results, ctx.root);
404 markdown::print_type_aware_markdown(ctx.type_aware, ctx.type_aware_scope);
405 ExitCode::SUCCESS
406 }
407 OutputFormat::CodeClimate => codeclimate::print_codeclimate(results, ctx.root, ctx.rules),
408 OutputFormat::GithubAnnotations => print_check_github_annotations(results, ctx),
409 OutputFormat::GithubSummary => {
410 print_check_github_format(results, ctx, GithubTarget::Summary)
411 }
412 ci_format => print_results_ci_comment(results, ctx, ci_format),
413 }
414}
415
416#[derive(Clone, Copy)]
418enum GithubTarget {
419 Annotations,
420 Summary,
421}
422
423fn print_github_format(
424 kind: github_annotations::EnvelopeKind,
425 envelope: &serde_json::Value,
426 root: &Path,
427 target: GithubTarget,
428) -> ExitCode {
429 match target {
430 GithubTarget::Annotations => github_annotations::print_annotations(kind, envelope, root),
431 GithubTarget::Summary => github_summary::print_summary(kind, envelope, root),
432 }
433}
434
435fn print_check_github_annotations(results: &AnalysisResults, ctx: &ReportContext<'_>) -> ExitCode {
440 print_check_github_format(results, ctx, GithubTarget::Annotations)
441}
442
443fn print_check_github_format(
444 results: &AnalysisResults,
445 ctx: &ReportContext<'_>,
446 target: GithubTarget,
447) -> ExitCode {
448 match json::api_check_json_document_with_config_fixable_meta_and_extras(
449 results,
450 ctx.root,
451 ctx.elapsed,
452 ctx.config_fixable,
453 None,
454 fallow_api::CheckJsonExtraOutputs::default(),
455 ctx.workspace_diagnostics,
456 ) {
457 Ok(envelope) => print_github_format(
458 github_annotations::EnvelopeKind::DeadCode,
459 &envelope,
460 ctx.root,
461 target,
462 ),
463 Err(e) => {
464 eprintln!("Error: failed to serialize results: {e}");
465 ExitCode::from(2)
466 }
467 }
468}
469
470pub(crate) fn ci_status_note(
477 existing: Option<&'static str>,
478 gates: Option<&fallow_output::GateOutcomes>,
479) -> Option<String> {
480 match (existing, gate_outcome_text::summary_line_for_gates(gates)) {
481 (Some(existing), Some(gates)) => Some(format!("{existing} {gates}")),
482 (Some(existing), None) => Some(existing.to_owned()),
483 (None, gates) => gates,
484 }
485}
486
487fn print_results_ci_comment(
489 results: &AnalysisResults,
490 ctx: &ReportContext<'_>,
491 output: OutputFormat,
492) -> ExitCode {
493 let issues = codeclimate::api_codeclimate_issues(results, ctx.root, ctx.rules);
497 let value = fallow_output::codeclimate_issues_to_value(&issues);
498 let incomplete = ci::required_type_aware_incomplete(ctx.type_aware);
499 let conclusion = incomplete.then_some(fallow_output::PrDecisionConclusion::Failure);
500 let status_message = ci_status_note(
501 incomplete.then_some(ci::TYPE_AWARE_INCOMPLETE_MESSAGE),
502 ctx.gate_outcomes.as_ref(),
503 );
504 print_ci_comment_format_with_status(
505 "dead-code",
506 &value,
507 output,
508 conclusion,
509 status_message.as_deref(),
510 )
511 .unwrap_or_else(|| {
512 eprintln!("Error: badge format is only supported for the health command");
513 ExitCode::from(2)
514 })
515}
516
517#[must_use]
519fn print_grouped_results(
520 groups: &[grouping::ResultGroup],
521 original: &AnalysisResults,
522 ctx: &ReportContext<'_>,
523 output: OutputFormat,
524 resolver: &OwnershipResolver,
525) -> ExitCode {
526 match output {
527 OutputFormat::Human => {
528 human::print_grouped_human(&human::PrintGroupedHumanInput {
529 groups,
530 root: ctx.root,
531 rules: ctx.rules,
532 elapsed: ctx.elapsed,
533 quiet: ctx.quiet,
534 resolver: Some(resolver),
535 explain: ctx.explain,
536 });
537 ExitCode::SUCCESS
538 }
539 OutputFormat::Json => json::print_grouped_json(&json::PrintGroupedJsonInput {
540 groups,
541 original,
542 root: ctx.root,
543 elapsed: ctx.elapsed,
544 explain: ctx.explain,
545 type_aware: ctx.type_aware,
546 resolver,
547 config_fixable: ctx.config_fixable,
548 baseline_staleness: ctx.baseline_staleness,
549 gate_outcomes: ctx.gate_outcomes.clone(),
550 workspace_diagnostics: ctx.workspace_diagnostics,
551 json_style: ctx.json_style,
552 }),
553 OutputFormat::Compact => {
554 compact::print_grouped_compact(groups, ctx.root);
555 compact::print_type_aware_compact(ctx.type_aware, ctx.type_aware_scope);
556 ExitCode::SUCCESS
557 }
558 OutputFormat::Markdown => {
559 markdown::print_grouped_markdown(groups, ctx.root);
560 markdown::print_type_aware_markdown(ctx.type_aware, ctx.type_aware_scope);
561 ExitCode::SUCCESS
562 }
563 OutputFormat::Sarif => {
564 sarif::print_grouped_sarif(original, ctx.root, ctx.rules, resolver, ctx.type_aware)
565 }
566 OutputFormat::CodeClimate => {
567 codeclimate::print_grouped_codeclimate(original, ctx.root, ctx.rules, resolver)
568 }
569 OutputFormat::GithubAnnotations => print_check_github_annotations(original, ctx),
572 OutputFormat::GithubSummary => {
573 print_check_github_format(original, ctx, GithubTarget::Summary)
574 }
575 ci_format => print_results_ci_comment(original, ctx, ci_format),
576 }
577}
578
579#[must_use]
581pub(crate) fn print_duplication_report(
582 report: &DuplicationReport,
583 ctx: &ReportContext<'_>,
584 output: OutputFormat,
585) -> ExitCode {
586 if let Some(ref resolver) = ctx.group_by {
587 let grouping = dupes_grouping::build_duplication_grouping(report, ctx.root, resolver);
588 return print_grouped_duplication_report(report, &grouping, ctx, output, resolver);
589 }
590
591 match output {
592 OutputFormat::Human => {
593 if ctx.summary {
594 human::dupes::print_duplication_summary(
595 report,
596 ctx.elapsed,
597 ctx.quiet,
598 ctx.summary_heading,
599 );
600 } else {
601 human::print_duplication_human(
602 report,
603 ctx.root,
604 ctx.elapsed,
605 ctx.quiet,
606 ctx.show_explain_tip,
607 ctx.explain,
608 );
609 }
610 ExitCode::SUCCESS
611 }
612 OutputFormat::Json => json::print_duplication_json(
613 report,
614 ctx.root,
615 ctx.elapsed,
616 &json::DuplicationJsonRender {
617 explain: ctx.explain,
618 include_fragments: ctx.include_fragments,
619 baseline_staleness: ctx.baseline_staleness,
620 gate_outcomes: ctx.gate_outcomes.clone(),
621 },
622 ctx.workspace_diagnostics,
623 ctx.json_style,
624 ),
625 OutputFormat::Compact => {
626 compact::print_duplication_compact(report, ctx.root);
627 ExitCode::SUCCESS
628 }
629 OutputFormat::Sarif => sarif::print_duplication_sarif(report, ctx.root),
630 OutputFormat::Markdown => {
631 markdown::print_duplication_markdown(report, ctx.root);
632 ExitCode::SUCCESS
633 }
634 OutputFormat::CodeClimate => codeclimate::print_duplication_codeclimate(report, ctx.root),
635 OutputFormat::GithubAnnotations => {
636 print_dupes_github_format(report, ctx, GithubTarget::Annotations)
637 }
638 OutputFormat::GithubSummary => {
639 print_dupes_github_format(report, ctx, GithubTarget::Summary)
640 }
641 ci_format => {
642 print_duplication_ci_comment(report, ctx.root, ci_format, ctx.gate_outcomes.as_ref())
643 }
644 }
645}
646
647fn print_dupes_github_format(
650 report: &DuplicationReport,
651 ctx: &ReportContext<'_>,
652 target: GithubTarget,
653) -> ExitCode {
654 match json::api_duplication_json_document(
655 report,
656 ctx.root,
657 ctx.elapsed,
658 &json::DuplicationJsonRender {
659 explain: ctx.explain,
660 include_fragments: ctx.include_fragments,
661 baseline_staleness: ctx.baseline_staleness,
662 gate_outcomes: ctx.gate_outcomes.clone(),
663 },
664 ctx.workspace_diagnostics,
665 ) {
666 Ok(envelope) => print_github_format(
667 github_annotations::EnvelopeKind::Dupes,
668 &envelope,
669 ctx.root,
670 target,
671 ),
672 Err(e) => {
673 eprintln!("Error: failed to serialize duplication report: {e}");
674 ExitCode::from(2)
675 }
676 }
677}
678
679fn print_duplication_ci_comment(
681 report: &DuplicationReport,
682 root: &Path,
683 output: OutputFormat,
684 gates: Option<&fallow_output::GateOutcomes>,
685) -> ExitCode {
686 let issues = codeclimate::api_duplication_codeclimate_issues(report, root);
687 let value = fallow_output::codeclimate_issues_to_value(&issues);
688 let gate_note = gate_outcome_text::summary_line_for_gates(gates);
689 print_ci_comment_format_with_status("dupes", &value, output, None, gate_note.as_deref())
690 .unwrap_or_else(|| {
691 eprintln!("Error: badge format is only supported for the health command");
692 ExitCode::from(2)
693 })
694}
695
696#[must_use]
698fn print_grouped_duplication_report(
699 report: &DuplicationReport,
700 grouping: &DuplicationGrouping,
701 ctx: &ReportContext<'_>,
702 output: OutputFormat,
703 resolver: &OwnershipResolver,
704) -> ExitCode {
705 match output {
706 OutputFormat::Human => {
707 human::print_grouped_duplication_human(
708 report,
709 grouping,
710 ctx.root,
711 ctx.elapsed,
712 ctx.quiet,
713 );
714 ExitCode::SUCCESS
715 }
716 OutputFormat::Json => json::print_grouped_duplication_json(
717 report,
718 grouping,
719 ctx.root,
720 ctx.elapsed,
721 &json::DuplicationJsonRender {
722 explain: ctx.explain,
723 include_fragments: ctx.include_fragments,
724 baseline_staleness: ctx.baseline_staleness,
725 gate_outcomes: ctx.gate_outcomes.clone(),
726 },
727 ctx.workspace_diagnostics,
728 ctx.json_style,
729 ),
730 OutputFormat::Sarif => sarif::print_grouped_duplication_sarif(report, ctx.root, resolver),
731 OutputFormat::CodeClimate => {
732 codeclimate::print_grouped_duplication_codeclimate(report, ctx.root, resolver)
733 }
734 OutputFormat::PrCommentGithub
735 | OutputFormat::PrCommentGitlab
736 | OutputFormat::ReviewGithub
737 | OutputFormat::ReviewGitlab => {
738 print_duplication_ci_comment(report, ctx.root, output, ctx.gate_outcomes.as_ref())
739 }
740 OutputFormat::GithubAnnotations => {
743 print_dupes_github_format(report, ctx, GithubTarget::Annotations)
744 }
745 OutputFormat::GithubSummary => {
746 print_dupes_github_format(report, ctx, GithubTarget::Summary)
747 }
748 OutputFormat::Compact => {
749 compact::print_duplication_compact(report, ctx.root);
750 warn_dupes_grouping_unsupported(grouping, "compact");
751 ExitCode::SUCCESS
752 }
753 OutputFormat::Markdown => {
754 markdown::print_duplication_markdown(report, ctx.root);
755 warn_dupes_grouping_unsupported(grouping, "markdown");
756 ExitCode::SUCCESS
757 }
758 OutputFormat::Badge => {
759 eprintln!("Error: badge format is only supported for the health command");
760 ExitCode::from(2)
761 }
762 }
763}
764
765fn print_ci_comment_format_with_status(
770 analysis: &str,
771 value: &serde_json::Value,
772 output: OutputFormat,
773 conclusion: Option<fallow_output::PrDecisionConclusion>,
774 status_message: Option<&str>,
775) -> Option<ExitCode> {
776 let exit = match output {
777 OutputFormat::PrCommentGithub => conclusion.map_or_else(
778 || {
779 ci::pr_comment::print_pr_comment(
780 analysis,
781 ci::pr_comment::Provider::Github,
782 value,
783 status_message,
784 )
785 },
786 |conclusion| {
787 ci::pr_comment::print_pr_comment_with_status(
788 analysis,
789 ci::pr_comment::Provider::Github,
790 value,
791 conclusion,
792 status_message,
793 )
794 },
795 ),
796 OutputFormat::PrCommentGitlab => conclusion.map_or_else(
797 || {
798 ci::pr_comment::print_pr_comment(
799 analysis,
800 ci::pr_comment::Provider::Gitlab,
801 value,
802 status_message,
803 )
804 },
805 |conclusion| {
806 ci::pr_comment::print_pr_comment_with_status(
807 analysis,
808 ci::pr_comment::Provider::Gitlab,
809 value,
810 conclusion,
811 status_message,
812 )
813 },
814 ),
815 OutputFormat::ReviewGithub => conclusion.map_or_else(
816 || {
817 ci::review::print_review_envelope(
818 analysis,
819 ci::pr_comment::Provider::Github,
820 value,
821 status_message,
822 )
823 },
824 |conclusion| {
825 ci::review::print_review_envelope_with_conclusion(
826 analysis,
827 ci::pr_comment::Provider::Github,
828 value,
829 conclusion,
830 status_message,
831 )
832 },
833 ),
834 OutputFormat::ReviewGitlab => conclusion.map_or_else(
835 || {
836 ci::review::print_review_envelope(
837 analysis,
838 ci::pr_comment::Provider::Gitlab,
839 value,
840 status_message,
841 )
842 },
843 |conclusion| {
844 ci::review::print_review_envelope_with_conclusion(
845 analysis,
846 ci::pr_comment::Provider::Gitlab,
847 value,
848 conclusion,
849 status_message,
850 )
851 },
852 ),
853 _ => return None,
854 };
855 Some(exit)
856}
857
858fn warn_dupes_grouping_unsupported(grouping: &DuplicationGrouping, format: &str) {
859 eprintln!(
860 "note: --group-by {} is not supported for {format} duplication output, falling back to \
861 ungrouped output (use --format json for the full grouped envelope)",
862 grouping.mode
863 );
864}
865
866#[must_use]
883pub(crate) fn print_health_report(
884 report: &fallow_output::HealthReport,
885 grouping: Option<&fallow_output::HealthGrouping>,
886 group_resolver: Option<&grouping::OwnershipResolver>,
887 ctx: &ReportContext<'_>,
888 output: OutputFormat,
889) -> ExitCode {
890 match output {
891 OutputFormat::Human => {
892 print_health_human_report(report, grouping, ctx);
893 ExitCode::SUCCESS
894 }
895 OutputFormat::Compact => {
896 compact::print_health_compact(report, ctx.root);
897 compact::print_type_aware_compact(ctx.type_aware, ctx.type_aware_scope);
898 warn_grouping_unsupported(grouping, "compact");
899 ExitCode::SUCCESS
900 }
901 OutputFormat::Markdown => {
902 markdown::print_health_markdown(report, ctx.root);
903 markdown::print_type_aware_markdown(ctx.type_aware, ctx.type_aware_scope);
904 warn_grouping_unsupported(grouping, "markdown");
905 ExitCode::SUCCESS
906 }
907 OutputFormat::Sarif => match group_resolver {
908 Some(resolver) => {
909 sarif::print_grouped_health_sarif(report, ctx.root, resolver, ctx.type_aware)
910 }
911 None => sarif::print_health_sarif(report, ctx.root, ctx.type_aware),
912 },
913 OutputFormat::Json => match grouping {
914 Some(grouping) => json::print_grouped_health_json(
915 report,
916 grouping,
917 ctx.root,
918 ctx.elapsed,
919 ctx.explain,
920 ctx.type_aware,
921 ctx.workspace_diagnostics,
922 ctx.json_style,
923 ctx.gate_outcomes.clone(),
924 ),
925 None => json::print_health_json(
926 report,
927 ctx.root,
928 ctx.elapsed,
929 ctx.explain,
930 ctx.type_aware,
931 ctx.workspace_diagnostics,
932 ctx.json_style,
933 ctx.gate_outcomes.clone(),
934 ),
935 },
936 OutputFormat::CodeClimate => match group_resolver {
937 Some(resolver) => {
938 codeclimate::print_grouped_health_codeclimate(report, ctx.root, resolver)
939 }
940 None => codeclimate::print_health_codeclimate(report, ctx.root),
941 },
942 OutputFormat::PrCommentGithub
943 | OutputFormat::PrCommentGitlab
944 | OutputFormat::ReviewGithub
945 | OutputFormat::ReviewGitlab => {
946 print_health_ci_comment(report, ctx.root, output, ctx.gate_outcomes.as_ref())
947 }
948 OutputFormat::GithubAnnotations => {
951 print_health_github_format(report, ctx, GithubTarget::Annotations)
952 }
953 OutputFormat::GithubSummary => {
954 print_health_github_format(report, ctx, GithubTarget::Summary)
955 }
956 OutputFormat::Badge => {
957 warn_grouping_unsupported(grouping, "badge");
958 badge::print_health_badge(report)
959 }
960 }
961}
962
963fn print_health_github_format(
966 report: &fallow_output::HealthReport,
967 ctx: &ReportContext<'_>,
968 target: GithubTarget,
969) -> ExitCode {
970 match json::api_health_json_document(
971 report,
972 ctx.root,
973 ctx.elapsed,
974 ctx.explain,
975 ctx.type_aware,
976 ctx.workspace_diagnostics,
977 ctx.gate_outcomes.clone(),
978 ) {
979 Ok(envelope) => print_github_format(
980 github_annotations::EnvelopeKind::Health,
981 &envelope,
982 ctx.root,
983 target,
984 ),
985 Err(e) => {
986 eprintln!("Error: failed to serialize health report: {e}");
987 ExitCode::from(2)
988 }
989 }
990}
991
992fn print_health_human_report(
994 report: &fallow_output::HealthReport,
995 grouping: Option<&fallow_output::HealthGrouping>,
996 ctx: &ReportContext<'_>,
997) {
998 if ctx.summary {
999 human::health::print_health_summary(report, ctx.elapsed, ctx.quiet, ctx.summary_heading);
1000 return;
1001 }
1002 human::print_health_human(&human::PrintHealthHumanInput {
1003 report,
1004 root: ctx.root,
1005 elapsed: ctx.elapsed,
1006 quiet: ctx.quiet,
1007 show_explain_tip: ctx.show_explain_tip,
1008 explain: ctx.explain,
1009 skip_score_and_trend: ctx.skip_score_and_trend,
1010 css_requested: ctx.css_requested,
1011 type_aware: ctx.type_aware,
1012 });
1013 if let Some(grouping) = grouping {
1014 human::print_health_grouping(grouping, ctx.root, ctx.quiet);
1015 }
1016}
1017
1018fn print_health_ci_comment(
1020 report: &fallow_output::HealthReport,
1021 root: &Path,
1022 output: OutputFormat,
1023 gates: Option<&fallow_output::GateOutcomes>,
1024) -> ExitCode {
1025 let issues = codeclimate::api_health_codeclimate_issues(report, root);
1026 let value = fallow_output::codeclimate_issues_to_value(&issues);
1027 let gate_note = gate_outcome_text::summary_line_for_gates(gates);
1028 print_ci_comment_format_with_status("health", &value, output, None, gate_note.as_deref())
1029 .unwrap_or_else(|| {
1030 eprintln!("Error: badge format is only supported for the health command");
1031 ExitCode::from(2)
1032 })
1033}
1034
1035fn warn_grouping_unsupported(grouping: Option<&fallow_output::HealthGrouping>, format: &str) {
1036 if let Some(g) = grouping {
1037 eprintln!(
1038 "note: --group-by {} is not supported for {format} output, falling back to \
1039 ungrouped output (use --format json for the full grouped envelope)",
1040 g.mode
1041 );
1042 }
1043}
1044
1045pub(crate) fn print_cross_reference_findings(
1049 cross_ref: &fallow_engine::cross_reference::CrossReferenceResult,
1050 root: &Path,
1051 quiet: bool,
1052 output: OutputFormat,
1053) {
1054 human::print_cross_reference_findings(cross_ref, root, quiet, output);
1055}
1056
1057pub(crate) fn print_export_trace(
1059 trace: &ExportTrace,
1060 format: OutputFormat,
1061 json_style: crate::json_style::JsonStyle,
1062) {
1063 match format {
1064 OutputFormat::Json => json::print_trace_json(trace, json_style),
1065 _ => human::print_export_trace_human(trace),
1066 }
1067}
1068
1069pub(crate) fn print_semantic_export_trace(
1072 trace: &ExportTrace,
1073 format: OutputFormat,
1074 explain: bool,
1075 json_style: crate::json_style::JsonStyle,
1076) {
1077 match format {
1078 OutputFormat::Json => json::print_semantic_trace_json(trace, explain, json_style),
1079 _ => human::print_export_trace_human(trace),
1080 }
1081}
1082
1083pub(crate) fn print_class_member_trace(
1085 trace: &fallow_engine::trace::ClassMemberTrace,
1086 format: OutputFormat,
1087 json_style: crate::json_style::JsonStyle,
1088) {
1089 match format {
1090 OutputFormat::Json => json::print_trace_json(trace, json_style),
1091 _ => human::print_class_member_trace_human(trace),
1092 }
1093}
1094
1095pub(crate) fn print_semantic_class_member_trace(
1098 trace: &fallow_engine::trace::ClassMemberTrace,
1099 format: OutputFormat,
1100 explain: bool,
1101 json_style: crate::json_style::JsonStyle,
1102) {
1103 match format {
1104 OutputFormat::Json => json::print_semantic_trace_json(trace, explain, json_style),
1105 _ => human::print_class_member_trace_human(trace),
1106 }
1107}
1108
1109pub(crate) fn print_file_trace(
1111 trace: &FileTrace,
1112 format: OutputFormat,
1113 json_style: crate::json_style::JsonStyle,
1114) {
1115 match format {
1116 OutputFormat::Json => json::print_trace_json(trace, json_style),
1117 _ => human::print_file_trace_human(trace),
1118 }
1119}
1120
1121pub(crate) fn print_dependency_trace(
1123 trace: &DependencyTrace,
1124 format: OutputFormat,
1125 json_style: crate::json_style::JsonStyle,
1126) {
1127 match format {
1128 OutputFormat::Json => json::print_trace_json(trace, json_style),
1129 _ => human::print_dependency_trace_human(trace),
1130 }
1131}
1132
1133pub(crate) fn print_clone_trace(
1135 trace: &CloneTrace,
1136 root: &Path,
1137 format: OutputFormat,
1138 json_style: crate::json_style::JsonStyle,
1139) {
1140 match format {
1141 OutputFormat::Json => json::print_trace_json(trace, json_style),
1142 _ => human::print_clone_trace_human(trace, root),
1143 }
1144}
1145
1146pub(crate) fn print_impact_closure_trace(
1149 trace: &ImpactClosureTrace,
1150 format: OutputFormat,
1151 json_style: crate::json_style::JsonStyle,
1152) {
1153 match format {
1154 OutputFormat::Json => json::print_trace_json(trace, json_style),
1155 _ => {
1156 outln!("Impact closure for {}", trace.seed);
1157 outln!(
1158 " affected beyond the diff: {} file{}",
1159 trace.affected_not_shown.len(),
1160 plural(trace.affected_not_shown.len())
1161 );
1162 for gap in &trace.coordination_gap {
1163 outln!(
1164 " coordination gap: {} consumes {}",
1165 gap.consumer_file,
1166 gap.consumed_symbols.join(", ")
1167 );
1168 }
1169 }
1170 }
1171}
1172
1173pub(crate) fn print_symbol_impact(
1175 impact: &SemanticSymbolImpact,
1176 format: OutputFormat,
1177 explain: bool,
1178 json_style: crate::json_style::JsonStyle,
1179) {
1180 match format {
1181 OutputFormat::Json => json::print_semantic_impact_json(impact, explain, json_style),
1182 _ => human::print_symbol_impact_human(impact),
1183 }
1184}
1185
1186pub(crate) fn print_performance(
1189 timings: &PipelineTimings,
1190 format: OutputFormat,
1191 json_style: crate::json_style::JsonStyle,
1192) {
1193 match format {
1194 OutputFormat::Json => match json_style.serialize(timings) {
1195 Ok(json) => eprintln!("{json}"),
1196 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
1197 },
1198 _ => human::print_performance_human(timings),
1199 }
1200}
1201
1202pub(crate) fn print_health_performance(
1205 timings: &fallow_output::HealthTimings,
1206 format: OutputFormat,
1207 json_style: crate::json_style::JsonStyle,
1208) {
1209 match format {
1210 OutputFormat::Json => match json_style.serialize(timings) {
1211 Ok(json) => eprintln!("{json}"),
1212 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
1213 },
1214 _ => human::print_health_performance_human(timings),
1215 }
1216}
1217
1218#[allow(
1219 unused_imports,
1220 reason = "target-dependent: used in lib, unused in bin"
1221)]
1222pub use fallow_api::build_compact_lines;
1223#[allow(
1224 unused_imports,
1225 reason = "target-dependent: used in lib, unused in bin"
1226)]
1227pub use fallow_api::build_duplication_markdown;
1228#[allow(
1229 unused_imports,
1230 reason = "target-dependent: used in lib, unused in bin"
1231)]
1232pub use fallow_api::build_health_markdown;
1233#[allow(
1234 unused_imports,
1235 reason = "target-dependent: used in lib, unused in bin"
1236)]
1237pub use fallow_api::build_markdown;
1238#[allow(
1239 clippy::redundant_pub_crate,
1240 reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
1241)]
1242pub(crate) use json::api_check_json_payload_with_config_fixable;
1243#[allow(
1244 clippy::redundant_pub_crate,
1245 reason = "target-dependent: report is public in lib, private in bin, but these adapters remain crate-internal"
1246)]
1247pub(crate) use json::{build_baseline_deltas_output, check_json_extras};
1248#[allow(
1249 unused_imports,
1250 reason = "target-dependent: used in lib, unused in bin"
1251)]
1252#[allow(
1253 clippy::redundant_pub_crate,
1254 reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
1255)]
1256pub(crate) use sarif::api_health_sarif_document;
1257#[allow(
1258 unused_imports,
1259 reason = "target-dependent: used in lib, unused in bin"
1260)]
1261#[allow(
1262 clippy::redundant_pub_crate,
1263 reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
1264)]
1265pub(crate) use sarif::api_sarif_document;
1266
1267#[cfg(test)]
1268mod tests {
1269 use super::*;
1270 use std::path::{Path, PathBuf};
1271
1272 #[test]
1273 fn format_bytes_pivots_at_power_of_1024() {
1274 assert_eq!(format_bytes(0), "0 B");
1275 assert_eq!(format_bytes(1023), "1023 B");
1276 assert_eq!(format_bytes(1024), "1 KiB");
1277 assert_eq!(format_bytes(2048), "2 KiB");
1278 assert_eq!(format_bytes(1_048_576), "1.0 MiB");
1279 assert_eq!(format_bytes(10_485_760), "10.0 MiB");
1280 assert_eq!(format_bytes(1_073_741_824), "1.0 GiB");
1281 }
1282
1283 fn test_context<'a>(root: &'a Path, rules: &'a RulesConfig) -> ReportContext<'a> {
1284 ReportContext {
1285 baseline_staleness: None,
1286 gate_outcomes: None,
1287 root,
1288 rules,
1289 workspace_diagnostics: &[],
1290 elapsed: Duration::default(),
1291 quiet: true,
1292 explain: false,
1293 type_aware: None,
1294 type_aware_scope: None,
1295 group_by: None,
1296 top: None,
1297 summary: false,
1298 summary_heading: false,
1299 show_explain_tip: false,
1300 baseline_matched: None,
1301 config_fixable: false,
1302 skip_score_and_trend: false,
1303 css_requested: false,
1304 json_style: crate::json_style::JsonStyle::Compact,
1305 include_fragments: true,
1306 }
1307 }
1308
1309 #[test]
1310 fn normalize_uri_forward_slashes_unchanged() {
1311 assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
1312 }
1313
1314 #[test]
1315 fn normalize_uri_backslashes_replaced() {
1316 assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
1317 }
1318
1319 #[test]
1320 fn normalize_uri_mixed_slashes() {
1321 assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
1322 }
1323
1324 #[test]
1325 fn normalize_uri_path_with_spaces() {
1326 assert_eq!(
1327 normalize_uri("src\\my folder\\file.ts"),
1328 "src/my folder/file.ts"
1329 );
1330 }
1331
1332 #[test]
1333 fn normalize_uri_empty_string() {
1334 assert_eq!(normalize_uri(""), "");
1335 }
1336
1337 #[test]
1338 fn relative_path_strips_root_prefix() {
1339 let root = Path::new("/project");
1340 let path = Path::new("/project/src/utils.ts");
1341 assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
1342 }
1343
1344 #[test]
1345 fn relative_path_returns_full_path_when_no_prefix() {
1346 let root = Path::new("/other");
1347 let path = Path::new("/project/src/utils.ts");
1348 assert_eq!(relative_path(path, root), path);
1349 }
1350
1351 #[test]
1352 fn relative_path_at_root_returns_empty_or_file() {
1353 let root = Path::new("/project");
1354 let path = Path::new("/project/file.ts");
1355 assert_eq!(relative_path(path, root), Path::new("file.ts"));
1356 }
1357
1358 #[test]
1359 fn relative_path_deeply_nested() {
1360 let root = Path::new("/project");
1361 let path = Path::new("/project/packages/ui/src/components/Button.tsx");
1362 assert_eq!(
1363 relative_path(path, root),
1364 Path::new("packages/ui/src/components/Button.tsx")
1365 );
1366 }
1367
1368 #[test]
1369 fn format_display_path_returns_workspace_relative() {
1370 let root = Path::new("/project");
1371 let path = Path::new("/project/apps/server/src/index.ts");
1372 assert_eq!(format_display_path(path, root), "apps/server/src/index.ts");
1373 }
1374
1375 #[test]
1376 fn format_display_path_collides_in_nx_layout_renders_full_relative() {
1377 let root = Path::new("/project");
1378 let server = Path::new("/project/apps/server/src/index.ts");
1379 let client = Path::new("/project/apps/client/src/index.ts");
1380 assert_eq!(
1381 format_display_path(server, root),
1382 "apps/server/src/index.ts"
1383 );
1384 assert_eq!(
1385 format_display_path(client, root),
1386 "apps/client/src/index.ts"
1387 );
1388 }
1389
1390 #[test]
1391 fn format_display_path_angular_component_renders_parent_directory() {
1392 let root = Path::new("/project");
1393 let path = Path::new(
1394 "/project/apps/admin/src/app/payments/payment-list/payment-list.component.html",
1395 );
1396 assert_eq!(
1397 format_display_path(path, root),
1398 "apps/admin/src/app/payments/payment-list/payment-list.component.html"
1399 );
1400 }
1401
1402 #[test]
1403 fn format_display_path_falls_back_to_full_path_when_root_does_not_prefix() {
1404 let root = Path::new("/other");
1405 let path = Path::new("/project/src/utils.ts");
1406 let rendered = format_display_path(path, root);
1407 assert!(rendered.contains("project"));
1408 assert!(rendered.ends_with("utils.ts"));
1409 assert!(!rendered.contains('\\'));
1410 }
1411
1412 #[test]
1413 fn format_display_path_normalizes_backslashes_to_forward_slashes() {
1414 let root = Path::new("/project");
1415 let path = Path::new("/project/src/sub\\file.ts");
1416 let rendered = format_display_path(path, root);
1417 assert!(
1418 !rendered.contains('\\'),
1419 "backslashes must be normalized: {rendered}"
1420 );
1421 }
1422
1423 #[test]
1424 fn format_display_path_handles_brackets_verbatim() {
1425 let root = Path::new("/project");
1426 let path = Path::new("/project/app/[slug]/page.tsx");
1427 assert_eq!(format_display_path(path, root), "app/[slug]/page.tsx");
1428 }
1429
1430 #[test]
1431 fn format_display_path_path_equals_root_returns_empty() {
1432 let root = Path::new("/project");
1433 let path = Path::new("/project");
1434 assert_eq!(format_display_path(path, root), "");
1435 }
1436
1437 #[test]
1438 fn format_display_path_basename_only_when_path_is_at_root() {
1439 let root = Path::new("/project");
1440 let path = Path::new("/project/Cargo.toml");
1441 assert_eq!(format_display_path(path, root), "Cargo.toml");
1442 }
1443
1444 #[test]
1445 fn relative_uri_produces_forward_slash_path() {
1446 let root = PathBuf::from("/project");
1447 let path = root.join("src").join("utils.ts");
1448 let uri = relative_uri(&path, &root);
1449 assert_eq!(uri, "src/utils.ts");
1450 }
1451
1452 #[test]
1453 fn relative_uri_encodes_brackets() {
1454 let root = PathBuf::from("/project");
1455 let path = root.join("src/app/[...slug]/page.tsx");
1456 let uri = relative_uri(&path, &root);
1457 assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
1458 }
1459
1460 #[test]
1461 fn relative_uri_encodes_nested_dynamic_routes() {
1462 let root = PathBuf::from("/project");
1463 let path = root.join("src/app/[slug]/[id]/page.tsx");
1464 let uri = relative_uri(&path, &root);
1465 assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
1466 }
1467
1468 #[test]
1469 fn relative_uri_no_common_prefix_returns_full() {
1470 let root = PathBuf::from("/other");
1471 let path = PathBuf::from("/project/src/utils.ts");
1472 let uri = relative_uri(&path, &root);
1473 assert!(uri.contains("project"));
1474 assert!(uri.contains("utils.ts"));
1475 }
1476
1477 #[test]
1478 fn severity_error_maps_to_level_error() {
1479 assert!(matches!(severity_to_level(Severity::Error), Level::Error));
1480 }
1481
1482 #[test]
1483 fn severity_warn_maps_to_level_warn() {
1484 assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
1485 }
1486
1487 #[test]
1488 fn severity_off_maps_to_level_info() {
1489 assert!(matches!(severity_to_level(Severity::Off), Level::Info));
1490 }
1491
1492 #[test]
1493 fn normalize_uri_single_bracket_pair() {
1494 assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
1495 }
1496
1497 #[test]
1498 fn normalize_uri_catch_all_route() {
1499 assert_eq!(
1500 normalize_uri("app/[...slug]/page.tsx"),
1501 "app/%5B...slug%5D/page.tsx"
1502 );
1503 }
1504
1505 #[test]
1506 fn normalize_uri_optional_catch_all_route() {
1507 assert_eq!(
1508 normalize_uri("app/[[...slug]]/page.tsx"),
1509 "app/%5B%5B...slug%5D%5D/page.tsx"
1510 );
1511 }
1512
1513 #[test]
1514 fn normalize_uri_multiple_dynamic_segments() {
1515 assert_eq!(
1516 normalize_uri("app/[lang]/posts/[id]"),
1517 "app/%5Blang%5D/posts/%5Bid%5D"
1518 );
1519 }
1520
1521 #[test]
1522 fn normalize_uri_no_special_chars() {
1523 let plain = "src/components/Button.tsx";
1524 assert_eq!(normalize_uri(plain), plain);
1525 }
1526
1527 #[test]
1528 fn normalize_uri_only_backslashes() {
1529 assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
1530 }
1531
1532 #[test]
1533 fn relative_path_identical_paths_returns_empty() {
1534 let root = Path::new("/project");
1535 assert_eq!(relative_path(root, root), Path::new(""));
1536 }
1537
1538 #[test]
1539 fn relative_path_partial_name_match_not_stripped() {
1540 let root = Path::new("/project");
1541 let path = Path::new("/project-two/src/a.ts");
1542 assert_eq!(relative_path(path, root), path);
1543 }
1544
1545 #[test]
1546 fn relative_uri_combines_stripping_and_encoding() {
1547 let root = PathBuf::from("/project");
1548 let path = root.join("src/app/[slug]/page.tsx");
1549 let uri = relative_uri(&path, &root);
1550 assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
1551 assert!(!uri.starts_with('/'));
1552 }
1553
1554 #[test]
1555 fn relative_uri_at_root_file() {
1556 let root = PathBuf::from("/project");
1557 let path = root.join("index.ts");
1558 assert_eq!(relative_uri(&path, &root), "index.ts");
1559 }
1560
1561 #[test]
1562 fn severity_to_level_is_const_evaluable() {
1563 const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
1564 const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
1565 const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
1566 assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
1567 assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
1568 assert!(matches!(LEVEL_FROM_OFF, Level::Info));
1569 }
1570
1571 #[test]
1572 fn level_is_copy() {
1573 let level = severity_to_level(Severity::Error);
1574 let copy = level;
1575 assert!(matches!(level, Level::Error));
1576 assert!(matches!(copy, Level::Error));
1577 }
1578
1579 #[test]
1580 fn print_results_rejects_badge_for_dead_code_reports() {
1581 let root = Path::new("/project");
1582 let rules = RulesConfig::default();
1583 let ctx = test_context(root, &rules);
1584
1585 let code = print_results(&AnalysisResults::default(), &ctx, OutputFormat::Badge, None);
1586
1587 assert_eq!(code, ExitCode::from(2));
1588 }
1589
1590 #[test]
1591 fn print_duplication_report_rejects_badge_format() {
1592 let root = Path::new("/project");
1593 let rules = RulesConfig::default();
1594 let ctx = test_context(root, &rules);
1595
1596 let code =
1597 print_duplication_report(&DuplicationReport::default(), &ctx, OutputFormat::Badge);
1598
1599 assert_eq!(code, ExitCode::from(2));
1600 }
1601
1602 #[test]
1603 fn elide_common_prefix_shared_dir() {
1604 assert_eq!(
1605 elide_common_prefix("src/components/A.tsx", "src/components/B.tsx"),
1606 "B.tsx"
1607 );
1608 }
1609
1610 #[test]
1611 fn elide_common_prefix_partial_shared() {
1612 assert_eq!(
1613 elide_common_prefix("src/components/A.tsx", "src/utils/B.tsx"),
1614 "utils/B.tsx"
1615 );
1616 }
1617
1618 #[test]
1619 fn elide_common_prefix_no_shared() {
1620 assert_eq!(
1621 elide_common_prefix("pkg-a/src/A.tsx", "pkg-b/src/B.tsx"),
1622 "pkg-b/src/B.tsx"
1623 );
1624 }
1625
1626 #[test]
1627 fn elide_common_prefix_identical_files() {
1628 assert_eq!(elide_common_prefix("a/b/x.ts", "a/b/y.ts"), "y.ts");
1629 }
1630
1631 #[test]
1632 fn elide_common_prefix_no_dirs() {
1633 assert_eq!(elide_common_prefix("foo.ts", "bar.ts"), "bar.ts");
1634 }
1635
1636 #[test]
1637 fn elide_common_prefix_deep_monorepo() {
1638 assert_eq!(
1639 elide_common_prefix(
1640 "packages/rap/src/rap/components/SearchSelect/SearchSelect.tsx",
1641 "packages/rap/src/rap/components/SearchSelect/SearchSelectItem.tsx"
1642 ),
1643 "SearchSelectItem.tsx"
1644 );
1645 }
1646
1647 #[test]
1648 fn split_dir_filename_with_dir() {
1649 let (dir, file) = split_dir_filename("src/utils/index.ts");
1650 assert_eq!(dir, "src/utils/");
1651 assert_eq!(file, "index.ts");
1652 }
1653
1654 #[test]
1655 fn split_dir_filename_no_dir() {
1656 let (dir, file) = split_dir_filename("file.ts");
1657 assert_eq!(dir, "");
1658 assert_eq!(file, "file.ts");
1659 }
1660
1661 #[test]
1662 fn split_dir_filename_deeply_nested() {
1663 let (dir, file) = split_dir_filename("a/b/c/d/e.ts");
1664 assert_eq!(dir, "a/b/c/d/");
1665 assert_eq!(file, "e.ts");
1666 }
1667
1668 #[test]
1669 fn split_dir_filename_trailing_slash() {
1670 let (dir, file) = split_dir_filename("src/");
1671 assert_eq!(dir, "src/");
1672 assert_eq!(file, "");
1673 }
1674
1675 #[test]
1676 fn split_dir_filename_empty() {
1677 let (dir, file) = split_dir_filename("");
1678 assert_eq!(dir, "");
1679 assert_eq!(file, "");
1680 }
1681
1682 #[test]
1683 fn plural_zero_is_plural() {
1684 assert_eq!(plural(0), "s");
1685 }
1686
1687 #[test]
1688 fn plural_one_is_singular() {
1689 assert_eq!(plural(1), "");
1690 }
1691
1692 #[test]
1693 fn plural_two_is_plural() {
1694 assert_eq!(plural(2), "s");
1695 }
1696
1697 #[test]
1698 fn plural_large_number() {
1699 assert_eq!(plural(999), "s");
1700 }
1701
1702 #[test]
1703 fn elide_common_prefix_empty_base() {
1704 assert_eq!(elide_common_prefix("", "src/foo.ts"), "src/foo.ts");
1705 }
1706
1707 #[test]
1708 fn elide_common_prefix_empty_target() {
1709 assert_eq!(elide_common_prefix("src/foo.ts", ""), "");
1710 }
1711
1712 #[test]
1713 fn elide_common_prefix_both_empty() {
1714 assert_eq!(elide_common_prefix("", ""), "");
1715 }
1716
1717 #[test]
1718 fn elide_common_prefix_same_file_different_extension() {
1719 assert_eq!(
1720 elide_common_prefix("src/utils.ts", "src/utils.js"),
1721 "utils.js"
1722 );
1723 }
1724
1725 #[test]
1726 fn elide_common_prefix_partial_filename_match_not_stripped() {
1727 assert_eq!(
1728 elide_common_prefix("src/App.tsx", "src/AppUtils.tsx"),
1729 "AppUtils.tsx"
1730 );
1731 }
1732
1733 #[test]
1734 fn elide_common_prefix_identical_paths() {
1735 assert_eq!(elide_common_prefix("src/foo.ts", "src/foo.ts"), "foo.ts");
1736 }
1737
1738 #[test]
1739 fn split_dir_filename_single_slash() {
1740 let (dir, file) = split_dir_filename("/file.ts");
1741 assert_eq!(dir, "/");
1742 assert_eq!(file, "file.ts");
1743 }
1744
1745 #[test]
1746 fn emit_json_returns_success_for_valid_value() {
1747 let value = serde_json::json!({"key": "value"});
1748 let code = emit_json(&value, "test");
1749 assert_eq!(code, ExitCode::SUCCESS);
1750 }
1751
1752 mod proptests {
1753 use super::*;
1754 use proptest::prelude::*;
1755
1756 proptest! {
1757 #[test]
1759 fn split_dir_filename_reconstructs_path(path in "[a-zA-Z0-9_./\\-]{0,100}") {
1760 let (dir, file) = split_dir_filename(&path);
1761 let reconstructed = format!("{dir}{file}");
1762 prop_assert_eq!(
1763 reconstructed, path,
1764 "dir+file should reconstruct the original path"
1765 );
1766 }
1767
1768 #[test]
1770 fn plural_returns_empty_or_s(n: usize) {
1771 let result = plural(n);
1772 prop_assert!(
1773 result.is_empty() || result == "s",
1774 "plural should return \"\" or \"s\", got {:?}",
1775 result
1776 );
1777 }
1778
1779 #[test]
1781 fn plural_singular_only_for_one(n: usize) {
1782 let result = plural(n);
1783 if n == 1 {
1784 prop_assert_eq!(result, "", "plural(1) should be empty");
1785 } else {
1786 prop_assert_eq!(result, "s", "plural({}) should be \"s\"", n);
1787 }
1788 }
1789
1790 #[test]
1792 fn normalize_uri_no_backslashes(path in "[a-zA-Z0-9_.\\\\/ \\[\\]%-]{0,100}") {
1793 let result = normalize_uri(&path);
1794 prop_assert!(
1795 !result.contains('\\'),
1796 "Result should not contain backslashes: {result}"
1797 );
1798 }
1799
1800 #[test]
1802 fn normalize_uri_encodes_all_brackets(path in "[a-zA-Z0-9_./\\[\\]%-]{0,80}") {
1803 let result = normalize_uri(&path);
1804 prop_assert!(
1805 !result.contains('[') && !result.contains(']'),
1806 "Result should not contain raw brackets: {result}"
1807 );
1808 }
1809
1810 #[test]
1812 fn elide_common_prefix_returns_suffix_of_target(
1813 base in "[a-zA-Z0-9_./]{0,50}",
1814 target in "[a-zA-Z0-9_./]{0,50}",
1815 ) {
1816 let result = elide_common_prefix(&base, &target);
1817 prop_assert!(
1818 target.ends_with(result),
1819 "Result {:?} should be a suffix of target {:?}",
1820 result, target
1821 );
1822 }
1823
1824 #[test]
1826 fn relative_path_never_panics(
1827 root in "/[a-zA-Z0-9_/]{0,30}",
1828 suffix in "[a-zA-Z0-9_./]{0,30}",
1829 ) {
1830 let root_path = Path::new(&root);
1831 let full = PathBuf::from(format!("{root}/{suffix}"));
1832 let _ = relative_path(&full, root_path);
1833 }
1834 }
1835 }
1836}