1mod badge;
2pub mod ci;
3pub mod codeclimate;
4mod compact;
5pub mod dupes_grouping;
6pub mod github;
7pub mod github_annotations;
8pub mod github_summary;
9pub mod grouping;
10mod human;
11mod json;
12mod markdown;
13mod sarif;
14mod shared;
15pub mod sink;
16pub mod suggestions;
17#[cfg(test)]
18pub mod test_helpers;
19
20use std::path::Path;
21use std::process::ExitCode;
22use std::time::Duration;
23
24use fallow_api::DuplicationGrouping;
25use fallow_config::{OutputFormat, RulesConfig, Severity};
26use fallow_types::duplicates::DuplicationReport;
27use fallow_types::results::AnalysisResults;
28use fallow_types::trace::{
29 CloneTrace, DependencyTrace, ExportTrace, FileTrace, ImpactClosureTrace, PipelineTimings,
30};
31
32use crate::report::sink::outln;
33
34#[allow(
35 unused_imports,
36 reason = "used by binary crate modules (combined.rs, audit.rs)"
37)]
38pub use fallow_output::strip_root_prefix;
39pub use grouping::OwnershipResolver;
40pub use human::health::{render_health_score, render_health_trend};
41
42pub struct WalkthroughHumanRender {
48 pub header: Vec<String>,
50 pub body: Vec<String>,
52 pub status: String,
54}
55
56#[must_use]
61pub fn walkthrough_viewed_files(
62 guide: &fallow_output::StandardWalkthroughGuide,
63 viewed: &crate::walkthrough_state::ViewedState,
64) -> Vec<String> {
65 human::walkthrough::viewed_files_for(guide, viewed)
66}
67
68#[must_use]
72pub fn build_walkthrough_human(
73 guide: &fallow_output::StandardWalkthroughGuide,
74 viewed: &crate::walkthrough_state::ViewedState,
75 show_cleared: bool,
76) -> WalkthroughHumanRender {
77 let input = human::walkthrough::WalkthroughHumanInput {
78 guide,
79 viewed,
80 show_cleared,
81 };
82 WalkthroughHumanRender {
83 header: human::walkthrough::build_focus_header(guide, viewed),
84 body: human::walkthrough::build_walkthrough_human_lines(&input),
85 status: human::walkthrough::build_status_line(guide, viewed),
86 }
87}
88
89pub struct ReportContext<'a> {
94 pub root: &'a Path,
95 pub rules: &'a RulesConfig,
96 pub elapsed: Duration,
97 pub quiet: bool,
98 pub explain: bool,
99 pub group_by: Option<OwnershipResolver>,
101 pub top: Option<usize>,
103 pub summary: bool,
105 pub summary_heading: bool,
109 pub show_explain_tip: bool,
111 pub baseline_matched: Option<(usize, usize)>,
113 pub config_fixable: bool,
118 pub skip_score_and_trend: bool,
123 pub css_requested: bool,
127 pub json_style: crate::json_style::JsonStyle,
129}
130
131#[must_use]
133pub fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
134 path.strip_prefix(root).unwrap_or(path)
135}
136
137#[must_use]
147pub fn format_display_path(path: &Path, root: &Path) -> String {
148 relative_path(path, root)
149 .display()
150 .to_string()
151 .replace('\\', "/")
152}
153
154#[must_use]
157pub fn split_dir_filename(path: &str) -> (&str, &str) {
158 path.rfind('/')
159 .map_or(("", path), |pos| (&path[..=pos], &path[pos + 1..]))
160}
161
162#[must_use]
164pub const fn plural(n: usize) -> &'static str {
165 if n == 1 { "" } else { "s" }
166}
167
168#[must_use]
173pub fn emit_json(value: &serde_json::Value, kind: &str) -> ExitCode {
174 match serde_json::to_string_pretty(value) {
175 Ok(json) => {
176 outln!("{json}");
177 ExitCode::SUCCESS
178 }
179 Err(e) => {
180 eprintln!("Error: failed to serialize {kind} output: {e}");
181 ExitCode::from(2)
182 }
183 }
184}
185
186#[must_use]
188pub fn emit_report_json(
189 value: &serde_json::Value,
190 kind: &str,
191 style: crate::json_style::JsonStyle,
192) -> ExitCode {
193 match style.serialize(value) {
194 Ok(json) => {
195 outln!("{json}");
196 ExitCode::SUCCESS
197 }
198 Err(e) => {
199 eprintln!("Error: failed to serialize {kind} output: {e}");
200 ExitCode::from(2)
201 }
202 }
203}
204
205#[must_use]
211pub fn elide_common_prefix<'a>(base: &str, target: &'a str) -> &'a str {
212 let mut last_sep = 0;
213 for (i, (a, b)) in base.bytes().zip(target.bytes()).enumerate() {
214 if a != b {
215 break;
216 }
217 if a == b'/' {
218 last_sep = i + 1;
219 }
220 }
221 if last_sep > 0 && last_sep <= target.len() {
222 &target[last_sep..]
223 } else {
224 target
225 }
226}
227
228#[cfg(test)]
230fn relative_uri(path: &Path, root: &Path) -> String {
231 normalize_uri(&relative_path(path, root).display().to_string())
232}
233
234#[must_use]
239pub fn normalize_uri(path_str: &str) -> String {
240 fallow_output::normalize_uri(path_str)
241}
242
243#[derive(Clone, Copy, Debug)]
245pub enum Level {
246 Warn,
247 Info,
248 Error,
249}
250
251#[must_use]
252pub const fn severity_to_level(s: Severity) -> Level {
253 match s {
254 Severity::Error => Level::Error,
255 Severity::Warn => Level::Warn,
256 Severity::Off => Level::Info,
257 }
258}
259
260#[must_use]
266pub fn print_results(
267 results: &AnalysisResults,
268 ctx: &ReportContext<'_>,
269 output: OutputFormat,
270 regression: Option<&crate::regression::RegressionOutcome>,
271) -> ExitCode {
272 if let Some(ref resolver) = ctx.group_by {
273 let groups = grouping::group_analysis_results(results, ctx.root, resolver);
274 return print_grouped_results(&groups, results, ctx, output, resolver);
275 }
276
277 match output {
278 OutputFormat::Human => {
279 if ctx.summary {
280 human::check::print_check_summary(
281 results,
282 ctx.rules,
283 ctx.elapsed,
284 ctx.quiet,
285 ctx.summary_heading,
286 );
287 } else {
288 human::print_human(&human::PrintHumanInput {
289 results,
290 root: ctx.root,
291 rules: ctx.rules,
292 elapsed: ctx.elapsed,
293 quiet: ctx.quiet,
294 top: ctx.top,
295 show_explain_tip: ctx.show_explain_tip,
296 explain: ctx.explain,
297 });
298 }
299 ExitCode::SUCCESS
300 }
301 OutputFormat::Json => json::print_json(&json::PrintJsonInput {
302 results,
303 root: ctx.root,
304 elapsed: ctx.elapsed,
305 explain: ctx.explain,
306 regression,
307 baseline_matched: ctx.baseline_matched,
308 config_fixable: ctx.config_fixable,
309 json_style: ctx.json_style,
310 }),
311 OutputFormat::Compact => {
312 compact::print_compact(results, ctx.root);
313 ExitCode::SUCCESS
314 }
315 OutputFormat::Sarif => sarif::print_sarif(results, ctx.root, ctx.rules),
316 OutputFormat::Markdown => {
317 markdown::print_markdown(results, ctx.root);
318 ExitCode::SUCCESS
319 }
320 OutputFormat::CodeClimate => codeclimate::print_codeclimate(results, ctx.root, ctx.rules),
321 OutputFormat::GithubAnnotations => print_check_github_annotations(results, ctx),
322 OutputFormat::GithubSummary => {
323 print_check_github_format(results, ctx, GithubTarget::Summary)
324 }
325 ci_format => print_results_ci_comment(results, ctx, ci_format),
326 }
327}
328
329#[derive(Clone, Copy)]
331enum GithubTarget {
332 Annotations,
333 Summary,
334}
335
336fn print_github_format(
337 kind: github_annotations::EnvelopeKind,
338 envelope: &serde_json::Value,
339 root: &Path,
340 target: GithubTarget,
341) -> ExitCode {
342 match target {
343 GithubTarget::Annotations => github_annotations::print_annotations(kind, envelope, root),
344 GithubTarget::Summary => github_summary::print_summary(kind, envelope, root),
345 }
346}
347
348fn print_check_github_annotations(results: &AnalysisResults, ctx: &ReportContext<'_>) -> ExitCode {
353 print_check_github_format(results, ctx, GithubTarget::Annotations)
354}
355
356fn print_check_github_format(
357 results: &AnalysisResults,
358 ctx: &ReportContext<'_>,
359 target: GithubTarget,
360) -> ExitCode {
361 match json::api_check_json_document_with_config_fixable_meta_and_extras(
362 results,
363 ctx.root,
364 ctx.elapsed,
365 ctx.config_fixable,
366 None,
367 fallow_api::CheckJsonExtraOutputs::default(),
368 ) {
369 Ok(envelope) => print_github_format(
370 github_annotations::EnvelopeKind::DeadCode,
371 &envelope,
372 ctx.root,
373 target,
374 ),
375 Err(e) => {
376 eprintln!("Error: failed to serialize results: {e}");
377 ExitCode::from(2)
378 }
379 }
380}
381
382fn print_results_ci_comment(
384 results: &AnalysisResults,
385 ctx: &ReportContext<'_>,
386 output: OutputFormat,
387) -> ExitCode {
388 let issues = codeclimate::api_codeclimate_issues(results, ctx.root, ctx.rules);
392 let value = fallow_output::codeclimate_issues_to_value(&issues);
393 print_ci_comment_format("dead-code", &value, output).unwrap_or_else(|| {
394 eprintln!("Error: badge format is only supported for the health command");
395 ExitCode::from(2)
396 })
397}
398
399#[must_use]
401fn print_grouped_results(
402 groups: &[grouping::ResultGroup],
403 original: &AnalysisResults,
404 ctx: &ReportContext<'_>,
405 output: OutputFormat,
406 resolver: &OwnershipResolver,
407) -> ExitCode {
408 match output {
409 OutputFormat::Human => {
410 human::print_grouped_human(&human::PrintGroupedHumanInput {
411 groups,
412 root: ctx.root,
413 rules: ctx.rules,
414 elapsed: ctx.elapsed,
415 quiet: ctx.quiet,
416 resolver: Some(resolver),
417 explain: ctx.explain,
418 });
419 ExitCode::SUCCESS
420 }
421 OutputFormat::Json => json::print_grouped_json(&json::PrintGroupedJsonInput {
422 groups,
423 original,
424 root: ctx.root,
425 elapsed: ctx.elapsed,
426 explain: ctx.explain,
427 resolver,
428 config_fixable: ctx.config_fixable,
429 json_style: ctx.json_style,
430 }),
431 OutputFormat::Compact => {
432 compact::print_grouped_compact(groups, ctx.root);
433 ExitCode::SUCCESS
434 }
435 OutputFormat::Markdown => {
436 markdown::print_grouped_markdown(groups, ctx.root);
437 ExitCode::SUCCESS
438 }
439 OutputFormat::Sarif => sarif::print_grouped_sarif(original, ctx.root, ctx.rules, resolver),
440 OutputFormat::CodeClimate => {
441 codeclimate::print_grouped_codeclimate(original, ctx.root, ctx.rules, resolver)
442 }
443 OutputFormat::GithubAnnotations => print_check_github_annotations(original, ctx),
446 OutputFormat::GithubSummary => {
447 print_check_github_format(original, ctx, GithubTarget::Summary)
448 }
449 ci_format => print_results_ci_comment(original, ctx, ci_format),
450 }
451}
452
453#[must_use]
455pub fn print_duplication_report(
456 report: &DuplicationReport,
457 ctx: &ReportContext<'_>,
458 output: OutputFormat,
459) -> ExitCode {
460 if let Some(ref resolver) = ctx.group_by {
461 let grouping = dupes_grouping::build_duplication_grouping(report, ctx.root, resolver);
462 return print_grouped_duplication_report(report, &grouping, ctx, output, resolver);
463 }
464
465 match output {
466 OutputFormat::Human => {
467 if ctx.summary {
468 human::dupes::print_duplication_summary(
469 report,
470 ctx.elapsed,
471 ctx.quiet,
472 ctx.summary_heading,
473 );
474 } else {
475 human::print_duplication_human(
476 report,
477 ctx.root,
478 ctx.elapsed,
479 ctx.quiet,
480 ctx.show_explain_tip,
481 ctx.explain,
482 );
483 }
484 ExitCode::SUCCESS
485 }
486 OutputFormat::Json => {
487 json::print_duplication_json(report, ctx.root, ctx.elapsed, ctx.explain, ctx.json_style)
488 }
489 OutputFormat::Compact => {
490 compact::print_duplication_compact(report, ctx.root);
491 ExitCode::SUCCESS
492 }
493 OutputFormat::Sarif => sarif::print_duplication_sarif(report, ctx.root),
494 OutputFormat::Markdown => {
495 markdown::print_duplication_markdown(report, ctx.root);
496 ExitCode::SUCCESS
497 }
498 OutputFormat::CodeClimate => codeclimate::print_duplication_codeclimate(report, ctx.root),
499 OutputFormat::GithubAnnotations => {
500 print_dupes_github_format(report, ctx, GithubTarget::Annotations)
501 }
502 OutputFormat::GithubSummary => {
503 print_dupes_github_format(report, ctx, GithubTarget::Summary)
504 }
505 ci_format => print_duplication_ci_comment(report, ctx.root, ci_format),
506 }
507}
508
509fn print_dupes_github_format(
512 report: &DuplicationReport,
513 ctx: &ReportContext<'_>,
514 target: GithubTarget,
515) -> ExitCode {
516 match json::api_duplication_json_document(report, ctx.root, ctx.elapsed, ctx.explain) {
517 Ok(envelope) => print_github_format(
518 github_annotations::EnvelopeKind::Dupes,
519 &envelope,
520 ctx.root,
521 target,
522 ),
523 Err(e) => {
524 eprintln!("Error: failed to serialize duplication report: {e}");
525 ExitCode::from(2)
526 }
527 }
528}
529
530fn print_duplication_ci_comment(
532 report: &DuplicationReport,
533 root: &Path,
534 output: OutputFormat,
535) -> ExitCode {
536 let issues = codeclimate::api_duplication_codeclimate_issues(report, root);
537 let value = fallow_output::codeclimate_issues_to_value(&issues);
538 print_ci_comment_format("dupes", &value, output).unwrap_or_else(|| {
539 eprintln!("Error: badge format is only supported for the health command");
540 ExitCode::from(2)
541 })
542}
543
544#[must_use]
546fn print_grouped_duplication_report(
547 report: &DuplicationReport,
548 grouping: &DuplicationGrouping,
549 ctx: &ReportContext<'_>,
550 output: OutputFormat,
551 resolver: &OwnershipResolver,
552) -> ExitCode {
553 match output {
554 OutputFormat::Human => {
555 human::print_grouped_duplication_human(
556 report,
557 grouping,
558 ctx.root,
559 ctx.elapsed,
560 ctx.quiet,
561 );
562 ExitCode::SUCCESS
563 }
564 OutputFormat::Json => json::print_grouped_duplication_json(
565 report,
566 grouping,
567 ctx.root,
568 ctx.elapsed,
569 ctx.explain,
570 ctx.json_style,
571 ),
572 OutputFormat::Sarif => sarif::print_grouped_duplication_sarif(report, ctx.root, resolver),
573 OutputFormat::CodeClimate => {
574 codeclimate::print_grouped_duplication_codeclimate(report, ctx.root, resolver)
575 }
576 OutputFormat::PrCommentGithub
577 | OutputFormat::PrCommentGitlab
578 | OutputFormat::ReviewGithub
579 | OutputFormat::ReviewGitlab => print_duplication_ci_comment(report, ctx.root, output),
580 OutputFormat::GithubAnnotations => {
583 print_dupes_github_format(report, ctx, GithubTarget::Annotations)
584 }
585 OutputFormat::GithubSummary => {
586 print_dupes_github_format(report, ctx, GithubTarget::Summary)
587 }
588 OutputFormat::Compact => {
589 compact::print_duplication_compact(report, ctx.root);
590 warn_dupes_grouping_unsupported(grouping, "compact");
591 ExitCode::SUCCESS
592 }
593 OutputFormat::Markdown => {
594 markdown::print_duplication_markdown(report, ctx.root);
595 warn_dupes_grouping_unsupported(grouping, "markdown");
596 ExitCode::SUCCESS
597 }
598 OutputFormat::Badge => {
599 eprintln!("Error: badge format is only supported for the health command");
600 ExitCode::from(2)
601 }
602 }
603}
604
605fn print_ci_comment_format(
610 analysis: &str,
611 value: &serde_json::Value,
612 output: OutputFormat,
613) -> Option<ExitCode> {
614 let exit = match output {
615 OutputFormat::PrCommentGithub => {
616 ci::pr_comment::print_pr_comment(analysis, ci::pr_comment::Provider::Github, value)
617 }
618 OutputFormat::PrCommentGitlab => {
619 ci::pr_comment::print_pr_comment(analysis, ci::pr_comment::Provider::Gitlab, value)
620 }
621 OutputFormat::ReviewGithub => {
622 ci::review::print_review_envelope(analysis, ci::pr_comment::Provider::Github, value)
623 }
624 OutputFormat::ReviewGitlab => {
625 ci::review::print_review_envelope(analysis, ci::pr_comment::Provider::Gitlab, value)
626 }
627 _ => return None,
628 };
629 Some(exit)
630}
631
632fn warn_dupes_grouping_unsupported(grouping: &DuplicationGrouping, format: &str) {
633 eprintln!(
634 "note: --group-by {} is not supported for {format} duplication output, falling back to \
635 ungrouped output (use --format json for the full grouped envelope)",
636 grouping.mode
637 );
638}
639
640#[must_use]
657pub fn print_health_report(
658 report: &fallow_output::HealthReport,
659 grouping: Option<&fallow_output::HealthGrouping>,
660 group_resolver: Option<&grouping::OwnershipResolver>,
661 ctx: &ReportContext<'_>,
662 output: OutputFormat,
663) -> ExitCode {
664 match output {
665 OutputFormat::Human => {
666 print_health_human_report(report, grouping, ctx);
667 ExitCode::SUCCESS
668 }
669 OutputFormat::Compact => {
670 compact::print_health_compact(report, ctx.root);
671 warn_grouping_unsupported(grouping, "compact");
672 ExitCode::SUCCESS
673 }
674 OutputFormat::Markdown => {
675 markdown::print_health_markdown(report, ctx.root);
676 warn_grouping_unsupported(grouping, "markdown");
677 ExitCode::SUCCESS
678 }
679 OutputFormat::Sarif => match group_resolver {
680 Some(resolver) => sarif::print_grouped_health_sarif(report, ctx.root, resolver),
681 None => sarif::print_health_sarif(report, ctx.root),
682 },
683 OutputFormat::Json => match grouping {
684 Some(grouping) => json::print_grouped_health_json(
685 report,
686 grouping,
687 ctx.root,
688 ctx.elapsed,
689 ctx.explain,
690 ctx.json_style,
691 ),
692 None => {
693 json::print_health_json(report, ctx.root, ctx.elapsed, ctx.explain, ctx.json_style)
694 }
695 },
696 OutputFormat::CodeClimate => match group_resolver {
697 Some(resolver) => {
698 codeclimate::print_grouped_health_codeclimate(report, ctx.root, resolver)
699 }
700 None => codeclimate::print_health_codeclimate(report, ctx.root),
701 },
702 OutputFormat::PrCommentGithub
703 | OutputFormat::PrCommentGitlab
704 | OutputFormat::ReviewGithub
705 | OutputFormat::ReviewGitlab => print_health_ci_comment(report, ctx.root, output),
706 OutputFormat::GithubAnnotations => {
709 print_health_github_format(report, ctx, GithubTarget::Annotations)
710 }
711 OutputFormat::GithubSummary => {
712 print_health_github_format(report, ctx, GithubTarget::Summary)
713 }
714 OutputFormat::Badge => {
715 warn_grouping_unsupported(grouping, "badge");
716 badge::print_health_badge(report)
717 }
718 }
719}
720
721fn print_health_github_format(
724 report: &fallow_output::HealthReport,
725 ctx: &ReportContext<'_>,
726 target: GithubTarget,
727) -> ExitCode {
728 match json::api_health_json_document(report, ctx.root, ctx.elapsed, ctx.explain) {
729 Ok(envelope) => print_github_format(
730 github_annotations::EnvelopeKind::Health,
731 &envelope,
732 ctx.root,
733 target,
734 ),
735 Err(e) => {
736 eprintln!("Error: failed to serialize health report: {e}");
737 ExitCode::from(2)
738 }
739 }
740}
741
742fn print_health_human_report(
744 report: &fallow_output::HealthReport,
745 grouping: Option<&fallow_output::HealthGrouping>,
746 ctx: &ReportContext<'_>,
747) {
748 if ctx.summary {
749 human::health::print_health_summary(report, ctx.elapsed, ctx.quiet, ctx.summary_heading);
750 return;
751 }
752 human::print_health_human(&human::PrintHealthHumanInput {
753 report,
754 root: ctx.root,
755 elapsed: ctx.elapsed,
756 quiet: ctx.quiet,
757 show_explain_tip: ctx.show_explain_tip,
758 explain: ctx.explain,
759 skip_score_and_trend: ctx.skip_score_and_trend,
760 css_requested: ctx.css_requested,
761 });
762 if let Some(grouping) = grouping {
763 human::print_health_grouping(grouping, ctx.root, ctx.quiet);
764 }
765}
766
767fn print_health_ci_comment(
769 report: &fallow_output::HealthReport,
770 root: &Path,
771 output: OutputFormat,
772) -> ExitCode {
773 let issues = codeclimate::api_health_codeclimate_issues(report, root);
774 let value = fallow_output::codeclimate_issues_to_value(&issues);
775 print_ci_comment_format("health", &value, output).unwrap_or_else(|| {
776 eprintln!("Error: badge format is only supported for the health command");
777 ExitCode::from(2)
778 })
779}
780
781fn warn_grouping_unsupported(grouping: Option<&fallow_output::HealthGrouping>, format: &str) {
782 if let Some(g) = grouping {
783 eprintln!(
784 "note: --group-by {} is not supported for {format} output, falling back to \
785 ungrouped output (use --format json for the full grouped envelope)",
786 g.mode
787 );
788 }
789}
790
791pub fn print_cross_reference_findings(
795 cross_ref: &fallow_engine::cross_reference::CrossReferenceResult,
796 root: &Path,
797 quiet: bool,
798 output: OutputFormat,
799) {
800 human::print_cross_reference_findings(cross_ref, root, quiet, output);
801}
802
803pub fn print_export_trace(
805 trace: &ExportTrace,
806 format: OutputFormat,
807 json_style: crate::json_style::JsonStyle,
808) {
809 match format {
810 OutputFormat::Json => json::print_trace_json(trace, json_style),
811 _ => human::print_export_trace_human(trace),
812 }
813}
814
815pub fn print_class_member_trace(
817 trace: &fallow_engine::trace::ClassMemberTrace,
818 format: OutputFormat,
819 json_style: crate::json_style::JsonStyle,
820) {
821 match format {
822 OutputFormat::Json => json::print_trace_json(trace, json_style),
823 _ => human::print_class_member_trace_human(trace),
824 }
825}
826
827pub fn print_file_trace(
829 trace: &FileTrace,
830 format: OutputFormat,
831 json_style: crate::json_style::JsonStyle,
832) {
833 match format {
834 OutputFormat::Json => json::print_trace_json(trace, json_style),
835 _ => human::print_file_trace_human(trace),
836 }
837}
838
839pub fn print_dependency_trace(
841 trace: &DependencyTrace,
842 format: OutputFormat,
843 json_style: crate::json_style::JsonStyle,
844) {
845 match format {
846 OutputFormat::Json => json::print_trace_json(trace, json_style),
847 _ => human::print_dependency_trace_human(trace),
848 }
849}
850
851pub fn print_clone_trace(
853 trace: &CloneTrace,
854 root: &Path,
855 format: OutputFormat,
856 json_style: crate::json_style::JsonStyle,
857) {
858 match format {
859 OutputFormat::Json => json::print_trace_json(trace, json_style),
860 _ => human::print_clone_trace_human(trace, root),
861 }
862}
863
864pub fn print_impact_closure_trace(
867 trace: &ImpactClosureTrace,
868 format: OutputFormat,
869 json_style: crate::json_style::JsonStyle,
870) {
871 match format {
872 OutputFormat::Json => json::print_trace_json(trace, json_style),
873 _ => {
874 outln!("Impact closure for {}", trace.seed);
875 outln!(
876 " affected beyond the diff: {} file{}",
877 trace.affected_not_shown.len(),
878 plural(trace.affected_not_shown.len())
879 );
880 for gap in &trace.coordination_gap {
881 outln!(
882 " coordination gap: {} consumes {}",
883 gap.consumer_file,
884 gap.consumed_symbols.join(", ")
885 );
886 }
887 }
888 }
889}
890
891pub fn print_performance(
894 timings: &PipelineTimings,
895 format: OutputFormat,
896 json_style: crate::json_style::JsonStyle,
897) {
898 match format {
899 OutputFormat::Json => match json_style.serialize(timings) {
900 Ok(json) => eprintln!("{json}"),
901 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
902 },
903 _ => human::print_performance_human(timings),
904 }
905}
906
907pub fn print_health_performance(
910 timings: &fallow_output::HealthTimings,
911 format: OutputFormat,
912 json_style: crate::json_style::JsonStyle,
913) {
914 match format {
915 OutputFormat::Json => match json_style.serialize(timings) {
916 Ok(json) => eprintln!("{json}"),
917 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
918 },
919 _ => human::print_health_performance_human(timings),
920 }
921}
922
923#[allow(
924 unused_imports,
925 reason = "target-dependent: used in lib, unused in bin"
926)]
927pub use fallow_api::build_compact_lines;
928#[allow(
929 unused_imports,
930 reason = "target-dependent: used in lib, unused in bin"
931)]
932pub use fallow_api::build_duplication_markdown;
933#[allow(
934 unused_imports,
935 reason = "target-dependent: used in lib, unused in bin"
936)]
937pub use fallow_api::build_health_markdown;
938#[allow(
939 unused_imports,
940 reason = "target-dependent: used in lib, unused in bin"
941)]
942pub use fallow_api::build_markdown;
943#[allow(
944 clippy::redundant_pub_crate,
945 reason = "pub(crate) deliberately limits visibility, report is pub but these are internal"
946)]
947pub(crate) use json::SCHEMA_VERSION;
948#[allow(
949 clippy::redundant_pub_crate,
950 reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
951)]
952pub(crate) use json::api_check_json_payload_with_config_fixable;
953#[allow(
954 clippy::redundant_pub_crate,
955 reason = "target-dependent: report is public in lib, private in bin, but these adapters remain crate-internal"
956)]
957pub(crate) use json::{build_baseline_deltas_output, check_json_extras};
958#[allow(
959 unused_imports,
960 reason = "target-dependent: used in lib, unused in bin"
961)]
962#[allow(
963 clippy::redundant_pub_crate,
964 reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
965)]
966pub(crate) use sarif::api_health_sarif_document;
967#[allow(
968 unused_imports,
969 reason = "target-dependent: used in lib, unused in bin"
970)]
971#[allow(
972 clippy::redundant_pub_crate,
973 reason = "target-dependent: report is public in lib, private in bin, but this adapter remains crate-internal"
974)]
975pub(crate) use sarif::api_sarif_document;
976
977#[cfg(test)]
978mod tests {
979 use super::*;
980 use std::path::{Path, PathBuf};
981
982 fn test_context<'a>(root: &'a Path, rules: &'a RulesConfig) -> ReportContext<'a> {
983 ReportContext {
984 root,
985 rules,
986 elapsed: Duration::default(),
987 quiet: true,
988 explain: false,
989 group_by: None,
990 top: None,
991 summary: false,
992 summary_heading: false,
993 show_explain_tip: false,
994 baseline_matched: None,
995 config_fixable: false,
996 skip_score_and_trend: false,
997 css_requested: false,
998 json_style: crate::json_style::JsonStyle::Compact,
999 }
1000 }
1001
1002 #[test]
1003 fn normalize_uri_forward_slashes_unchanged() {
1004 assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
1005 }
1006
1007 #[test]
1008 fn normalize_uri_backslashes_replaced() {
1009 assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
1010 }
1011
1012 #[test]
1013 fn normalize_uri_mixed_slashes() {
1014 assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
1015 }
1016
1017 #[test]
1018 fn normalize_uri_path_with_spaces() {
1019 assert_eq!(
1020 normalize_uri("src\\my folder\\file.ts"),
1021 "src/my folder/file.ts"
1022 );
1023 }
1024
1025 #[test]
1026 fn normalize_uri_empty_string() {
1027 assert_eq!(normalize_uri(""), "");
1028 }
1029
1030 #[test]
1031 fn relative_path_strips_root_prefix() {
1032 let root = Path::new("/project");
1033 let path = Path::new("/project/src/utils.ts");
1034 assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
1035 }
1036
1037 #[test]
1038 fn relative_path_returns_full_path_when_no_prefix() {
1039 let root = Path::new("/other");
1040 let path = Path::new("/project/src/utils.ts");
1041 assert_eq!(relative_path(path, root), path);
1042 }
1043
1044 #[test]
1045 fn relative_path_at_root_returns_empty_or_file() {
1046 let root = Path::new("/project");
1047 let path = Path::new("/project/file.ts");
1048 assert_eq!(relative_path(path, root), Path::new("file.ts"));
1049 }
1050
1051 #[test]
1052 fn relative_path_deeply_nested() {
1053 let root = Path::new("/project");
1054 let path = Path::new("/project/packages/ui/src/components/Button.tsx");
1055 assert_eq!(
1056 relative_path(path, root),
1057 Path::new("packages/ui/src/components/Button.tsx")
1058 );
1059 }
1060
1061 #[test]
1062 fn format_display_path_returns_workspace_relative() {
1063 let root = Path::new("/project");
1064 let path = Path::new("/project/apps/server/src/index.ts");
1065 assert_eq!(format_display_path(path, root), "apps/server/src/index.ts");
1066 }
1067
1068 #[test]
1069 fn format_display_path_collides_in_nx_layout_renders_full_relative() {
1070 let root = Path::new("/project");
1071 let server = Path::new("/project/apps/server/src/index.ts");
1072 let client = Path::new("/project/apps/client/src/index.ts");
1073 assert_eq!(
1074 format_display_path(server, root),
1075 "apps/server/src/index.ts"
1076 );
1077 assert_eq!(
1078 format_display_path(client, root),
1079 "apps/client/src/index.ts"
1080 );
1081 }
1082
1083 #[test]
1084 fn format_display_path_angular_component_renders_parent_directory() {
1085 let root = Path::new("/project");
1086 let path = Path::new(
1087 "/project/apps/admin/src/app/payments/payment-list/payment-list.component.html",
1088 );
1089 assert_eq!(
1090 format_display_path(path, root),
1091 "apps/admin/src/app/payments/payment-list/payment-list.component.html"
1092 );
1093 }
1094
1095 #[test]
1096 fn format_display_path_falls_back_to_full_path_when_root_does_not_prefix() {
1097 let root = Path::new("/other");
1098 let path = Path::new("/project/src/utils.ts");
1099 let rendered = format_display_path(path, root);
1100 assert!(rendered.contains("project"));
1101 assert!(rendered.ends_with("utils.ts"));
1102 assert!(!rendered.contains('\\'));
1103 }
1104
1105 #[test]
1106 fn format_display_path_normalizes_backslashes_to_forward_slashes() {
1107 let root = Path::new("/project");
1108 let path = Path::new("/project/src/sub\\file.ts");
1109 let rendered = format_display_path(path, root);
1110 assert!(
1111 !rendered.contains('\\'),
1112 "backslashes must be normalized: {rendered}"
1113 );
1114 }
1115
1116 #[test]
1117 fn format_display_path_handles_brackets_verbatim() {
1118 let root = Path::new("/project");
1119 let path = Path::new("/project/app/[slug]/page.tsx");
1120 assert_eq!(format_display_path(path, root), "app/[slug]/page.tsx");
1121 }
1122
1123 #[test]
1124 fn format_display_path_path_equals_root_returns_empty() {
1125 let root = Path::new("/project");
1126 let path = Path::new("/project");
1127 assert_eq!(format_display_path(path, root), "");
1128 }
1129
1130 #[test]
1131 fn format_display_path_basename_only_when_path_is_at_root() {
1132 let root = Path::new("/project");
1133 let path = Path::new("/project/Cargo.toml");
1134 assert_eq!(format_display_path(path, root), "Cargo.toml");
1135 }
1136
1137 #[test]
1138 fn relative_uri_produces_forward_slash_path() {
1139 let root = PathBuf::from("/project");
1140 let path = root.join("src").join("utils.ts");
1141 let uri = relative_uri(&path, &root);
1142 assert_eq!(uri, "src/utils.ts");
1143 }
1144
1145 #[test]
1146 fn relative_uri_encodes_brackets() {
1147 let root = PathBuf::from("/project");
1148 let path = root.join("src/app/[...slug]/page.tsx");
1149 let uri = relative_uri(&path, &root);
1150 assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
1151 }
1152
1153 #[test]
1154 fn relative_uri_encodes_nested_dynamic_routes() {
1155 let root = PathBuf::from("/project");
1156 let path = root.join("src/app/[slug]/[id]/page.tsx");
1157 let uri = relative_uri(&path, &root);
1158 assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
1159 }
1160
1161 #[test]
1162 fn relative_uri_no_common_prefix_returns_full() {
1163 let root = PathBuf::from("/other");
1164 let path = PathBuf::from("/project/src/utils.ts");
1165 let uri = relative_uri(&path, &root);
1166 assert!(uri.contains("project"));
1167 assert!(uri.contains("utils.ts"));
1168 }
1169
1170 #[test]
1171 fn severity_error_maps_to_level_error() {
1172 assert!(matches!(severity_to_level(Severity::Error), Level::Error));
1173 }
1174
1175 #[test]
1176 fn severity_warn_maps_to_level_warn() {
1177 assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
1178 }
1179
1180 #[test]
1181 fn severity_off_maps_to_level_info() {
1182 assert!(matches!(severity_to_level(Severity::Off), Level::Info));
1183 }
1184
1185 #[test]
1186 fn normalize_uri_single_bracket_pair() {
1187 assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
1188 }
1189
1190 #[test]
1191 fn normalize_uri_catch_all_route() {
1192 assert_eq!(
1193 normalize_uri("app/[...slug]/page.tsx"),
1194 "app/%5B...slug%5D/page.tsx"
1195 );
1196 }
1197
1198 #[test]
1199 fn normalize_uri_optional_catch_all_route() {
1200 assert_eq!(
1201 normalize_uri("app/[[...slug]]/page.tsx"),
1202 "app/%5B%5B...slug%5D%5D/page.tsx"
1203 );
1204 }
1205
1206 #[test]
1207 fn normalize_uri_multiple_dynamic_segments() {
1208 assert_eq!(
1209 normalize_uri("app/[lang]/posts/[id]"),
1210 "app/%5Blang%5D/posts/%5Bid%5D"
1211 );
1212 }
1213
1214 #[test]
1215 fn normalize_uri_no_special_chars() {
1216 let plain = "src/components/Button.tsx";
1217 assert_eq!(normalize_uri(plain), plain);
1218 }
1219
1220 #[test]
1221 fn normalize_uri_only_backslashes() {
1222 assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
1223 }
1224
1225 #[test]
1226 fn relative_path_identical_paths_returns_empty() {
1227 let root = Path::new("/project");
1228 assert_eq!(relative_path(root, root), Path::new(""));
1229 }
1230
1231 #[test]
1232 fn relative_path_partial_name_match_not_stripped() {
1233 let root = Path::new("/project");
1234 let path = Path::new("/project-two/src/a.ts");
1235 assert_eq!(relative_path(path, root), path);
1236 }
1237
1238 #[test]
1239 fn relative_uri_combines_stripping_and_encoding() {
1240 let root = PathBuf::from("/project");
1241 let path = root.join("src/app/[slug]/page.tsx");
1242 let uri = relative_uri(&path, &root);
1243 assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
1244 assert!(!uri.starts_with('/'));
1245 }
1246
1247 #[test]
1248 fn relative_uri_at_root_file() {
1249 let root = PathBuf::from("/project");
1250 let path = root.join("index.ts");
1251 assert_eq!(relative_uri(&path, &root), "index.ts");
1252 }
1253
1254 #[test]
1255 fn severity_to_level_is_const_evaluable() {
1256 const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
1257 const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
1258 const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
1259 assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
1260 assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
1261 assert!(matches!(LEVEL_FROM_OFF, Level::Info));
1262 }
1263
1264 #[test]
1265 fn level_is_copy() {
1266 let level = severity_to_level(Severity::Error);
1267 let copy = level;
1268 assert!(matches!(level, Level::Error));
1269 assert!(matches!(copy, Level::Error));
1270 }
1271
1272 #[test]
1273 fn print_results_rejects_badge_for_dead_code_reports() {
1274 let root = Path::new("/project");
1275 let rules = RulesConfig::default();
1276 let ctx = test_context(root, &rules);
1277
1278 let code = print_results(&AnalysisResults::default(), &ctx, OutputFormat::Badge, None);
1279
1280 assert_eq!(code, ExitCode::from(2));
1281 }
1282
1283 #[test]
1284 fn print_duplication_report_rejects_badge_format() {
1285 let root = Path::new("/project");
1286 let rules = RulesConfig::default();
1287 let ctx = test_context(root, &rules);
1288
1289 let code =
1290 print_duplication_report(&DuplicationReport::default(), &ctx, OutputFormat::Badge);
1291
1292 assert_eq!(code, ExitCode::from(2));
1293 }
1294
1295 #[test]
1296 fn elide_common_prefix_shared_dir() {
1297 assert_eq!(
1298 elide_common_prefix("src/components/A.tsx", "src/components/B.tsx"),
1299 "B.tsx"
1300 );
1301 }
1302
1303 #[test]
1304 fn elide_common_prefix_partial_shared() {
1305 assert_eq!(
1306 elide_common_prefix("src/components/A.tsx", "src/utils/B.tsx"),
1307 "utils/B.tsx"
1308 );
1309 }
1310
1311 #[test]
1312 fn elide_common_prefix_no_shared() {
1313 assert_eq!(
1314 elide_common_prefix("pkg-a/src/A.tsx", "pkg-b/src/B.tsx"),
1315 "pkg-b/src/B.tsx"
1316 );
1317 }
1318
1319 #[test]
1320 fn elide_common_prefix_identical_files() {
1321 assert_eq!(elide_common_prefix("a/b/x.ts", "a/b/y.ts"), "y.ts");
1322 }
1323
1324 #[test]
1325 fn elide_common_prefix_no_dirs() {
1326 assert_eq!(elide_common_prefix("foo.ts", "bar.ts"), "bar.ts");
1327 }
1328
1329 #[test]
1330 fn elide_common_prefix_deep_monorepo() {
1331 assert_eq!(
1332 elide_common_prefix(
1333 "packages/rap/src/rap/components/SearchSelect/SearchSelect.tsx",
1334 "packages/rap/src/rap/components/SearchSelect/SearchSelectItem.tsx"
1335 ),
1336 "SearchSelectItem.tsx"
1337 );
1338 }
1339
1340 #[test]
1341 fn split_dir_filename_with_dir() {
1342 let (dir, file) = split_dir_filename("src/utils/index.ts");
1343 assert_eq!(dir, "src/utils/");
1344 assert_eq!(file, "index.ts");
1345 }
1346
1347 #[test]
1348 fn split_dir_filename_no_dir() {
1349 let (dir, file) = split_dir_filename("file.ts");
1350 assert_eq!(dir, "");
1351 assert_eq!(file, "file.ts");
1352 }
1353
1354 #[test]
1355 fn split_dir_filename_deeply_nested() {
1356 let (dir, file) = split_dir_filename("a/b/c/d/e.ts");
1357 assert_eq!(dir, "a/b/c/d/");
1358 assert_eq!(file, "e.ts");
1359 }
1360
1361 #[test]
1362 fn split_dir_filename_trailing_slash() {
1363 let (dir, file) = split_dir_filename("src/");
1364 assert_eq!(dir, "src/");
1365 assert_eq!(file, "");
1366 }
1367
1368 #[test]
1369 fn split_dir_filename_empty() {
1370 let (dir, file) = split_dir_filename("");
1371 assert_eq!(dir, "");
1372 assert_eq!(file, "");
1373 }
1374
1375 #[test]
1376 fn plural_zero_is_plural() {
1377 assert_eq!(plural(0), "s");
1378 }
1379
1380 #[test]
1381 fn plural_one_is_singular() {
1382 assert_eq!(plural(1), "");
1383 }
1384
1385 #[test]
1386 fn plural_two_is_plural() {
1387 assert_eq!(plural(2), "s");
1388 }
1389
1390 #[test]
1391 fn plural_large_number() {
1392 assert_eq!(plural(999), "s");
1393 }
1394
1395 #[test]
1396 fn elide_common_prefix_empty_base() {
1397 assert_eq!(elide_common_prefix("", "src/foo.ts"), "src/foo.ts");
1398 }
1399
1400 #[test]
1401 fn elide_common_prefix_empty_target() {
1402 assert_eq!(elide_common_prefix("src/foo.ts", ""), "");
1403 }
1404
1405 #[test]
1406 fn elide_common_prefix_both_empty() {
1407 assert_eq!(elide_common_prefix("", ""), "");
1408 }
1409
1410 #[test]
1411 fn elide_common_prefix_same_file_different_extension() {
1412 assert_eq!(
1413 elide_common_prefix("src/utils.ts", "src/utils.js"),
1414 "utils.js"
1415 );
1416 }
1417
1418 #[test]
1419 fn elide_common_prefix_partial_filename_match_not_stripped() {
1420 assert_eq!(
1421 elide_common_prefix("src/App.tsx", "src/AppUtils.tsx"),
1422 "AppUtils.tsx"
1423 );
1424 }
1425
1426 #[test]
1427 fn elide_common_prefix_identical_paths() {
1428 assert_eq!(elide_common_prefix("src/foo.ts", "src/foo.ts"), "foo.ts");
1429 }
1430
1431 #[test]
1432 fn split_dir_filename_single_slash() {
1433 let (dir, file) = split_dir_filename("/file.ts");
1434 assert_eq!(dir, "/");
1435 assert_eq!(file, "file.ts");
1436 }
1437
1438 #[test]
1439 fn emit_json_returns_success_for_valid_value() {
1440 let value = serde_json::json!({"key": "value"});
1441 let code = emit_json(&value, "test");
1442 assert_eq!(code, ExitCode::SUCCESS);
1443 }
1444
1445 mod proptests {
1446 use super::*;
1447 use proptest::prelude::*;
1448
1449 proptest! {
1450 #[test]
1452 fn split_dir_filename_reconstructs_path(path in "[a-zA-Z0-9_./\\-]{0,100}") {
1453 let (dir, file) = split_dir_filename(&path);
1454 let reconstructed = format!("{dir}{file}");
1455 prop_assert_eq!(
1456 reconstructed, path,
1457 "dir+file should reconstruct the original path"
1458 );
1459 }
1460
1461 #[test]
1463 fn plural_returns_empty_or_s(n: usize) {
1464 let result = plural(n);
1465 prop_assert!(
1466 result.is_empty() || result == "s",
1467 "plural should return \"\" or \"s\", got {:?}",
1468 result
1469 );
1470 }
1471
1472 #[test]
1474 fn plural_singular_only_for_one(n: usize) {
1475 let result = plural(n);
1476 if n == 1 {
1477 prop_assert_eq!(result, "", "plural(1) should be empty");
1478 } else {
1479 prop_assert_eq!(result, "s", "plural({}) should be \"s\"", n);
1480 }
1481 }
1482
1483 #[test]
1485 fn normalize_uri_no_backslashes(path in "[a-zA-Z0-9_.\\\\/ \\[\\]%-]{0,100}") {
1486 let result = normalize_uri(&path);
1487 prop_assert!(
1488 !result.contains('\\'),
1489 "Result should not contain backslashes: {result}"
1490 );
1491 }
1492
1493 #[test]
1495 fn normalize_uri_encodes_all_brackets(path in "[a-zA-Z0-9_./\\[\\]%-]{0,80}") {
1496 let result = normalize_uri(&path);
1497 prop_assert!(
1498 !result.contains('[') && !result.contains(']'),
1499 "Result should not contain raw brackets: {result}"
1500 );
1501 }
1502
1503 #[test]
1505 fn elide_common_prefix_returns_suffix_of_target(
1506 base in "[a-zA-Z0-9_./]{0,50}",
1507 target in "[a-zA-Z0-9_./]{0,50}",
1508 ) {
1509 let result = elide_common_prefix(&base, &target);
1510 prop_assert!(
1511 target.ends_with(result),
1512 "Result {:?} should be a suffix of target {:?}",
1513 result, target
1514 );
1515 }
1516
1517 #[test]
1519 fn relative_path_never_panics(
1520 root in "/[a-zA-Z0-9_/]{0,30}",
1521 suffix in "[a-zA-Z0-9_./]{0,30}",
1522 ) {
1523 let root_path = Path::new(&root);
1524 let full = PathBuf::from(format!("{root}/{suffix}"));
1525 let _ = relative_path(&full, root_path);
1526 }
1527 }
1528 }
1529}