1mod badge;
2mod codeclimate;
3mod compact;
4mod human;
5mod json;
6mod markdown;
7mod sarif;
8#[cfg(test)]
9mod test_helpers;
10
11use std::path::Path;
12use std::process::ExitCode;
13use std::time::Duration;
14
15use fallow_config::{OutputFormat, RulesConfig, Severity};
16use fallow_core::duplicates::DuplicationReport;
17use fallow_core::results::AnalysisResults;
18use fallow_core::trace::{CloneTrace, DependencyTrace, ExportTrace, FileTrace, PipelineTimings};
19
20pub struct ReportContext<'a> {
25 pub root: &'a Path,
26 pub rules: &'a RulesConfig,
27 pub elapsed: Duration,
28 pub quiet: bool,
29 pub explain: bool,
30}
31
32#[must_use]
34pub fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
35 path.strip_prefix(root).unwrap_or(path)
36}
37
38#[must_use]
41pub fn split_dir_filename(path: &str) -> (&str, &str) {
42 path.rfind('/')
43 .map_or(("", path), |pos| (&path[..=pos], &path[pos + 1..]))
44}
45
46#[must_use]
48pub const fn plural(n: usize) -> &'static str {
49 if n == 1 { "" } else { "s" }
50}
51
52#[must_use]
57pub fn emit_json(value: &serde_json::Value, kind: &str) -> ExitCode {
58 match serde_json::to_string_pretty(value) {
59 Ok(json) => {
60 println!("{json}");
61 ExitCode::SUCCESS
62 }
63 Err(e) => {
64 eprintln!("Error: failed to serialize {kind} output: {e}");
65 ExitCode::from(2)
66 }
67 }
68}
69
70#[must_use]
76pub fn elide_common_prefix<'a>(base: &str, target: &'a str) -> &'a str {
77 let mut last_sep = 0;
78 for (i, (a, b)) in base.bytes().zip(target.bytes()).enumerate() {
79 if a != b {
80 break;
81 }
82 if a == b'/' {
83 last_sep = i + 1;
84 }
85 }
86 if last_sep > 0 && last_sep <= target.len() {
87 &target[last_sep..]
88 } else {
89 target
90 }
91}
92
93fn relative_uri(path: &Path, root: &Path) -> String {
95 normalize_uri(&relative_path(path, root).display().to_string())
96}
97
98#[must_use]
103pub fn normalize_uri(path_str: &str) -> String {
104 path_str
105 .replace('\\', "/")
106 .replace('[', "%5B")
107 .replace(']', "%5D")
108}
109
110#[derive(Clone, Copy, Debug)]
112pub enum Level {
113 Warn,
114 Info,
115 Error,
116}
117
118#[must_use]
119pub const fn severity_to_level(s: Severity) -> Level {
120 match s {
121 Severity::Error => Level::Error,
122 Severity::Warn => Level::Warn,
123 Severity::Off => Level::Info,
125 }
126}
127
128#[must_use]
133pub fn print_results(
134 results: &AnalysisResults,
135 ctx: &ReportContext<'_>,
136 output: OutputFormat,
137 regression: Option<&crate::regression::RegressionOutcome>,
138) -> ExitCode {
139 match output {
140 OutputFormat::Human => {
141 human::print_human(results, ctx.root, ctx.rules, ctx.elapsed, ctx.quiet);
142 ExitCode::SUCCESS
143 }
144 OutputFormat::Json => {
145 json::print_json(results, ctx.root, ctx.elapsed, ctx.explain, regression)
146 }
147 OutputFormat::Compact => {
148 compact::print_compact(results, ctx.root);
149 ExitCode::SUCCESS
150 }
151 OutputFormat::Sarif => sarif::print_sarif(results, ctx.root, ctx.rules),
152 OutputFormat::Markdown => {
153 markdown::print_markdown(results, ctx.root);
154 ExitCode::SUCCESS
155 }
156 OutputFormat::CodeClimate => codeclimate::print_codeclimate(results, ctx.root, ctx.rules),
157 OutputFormat::Badge => {
158 eprintln!("Error: badge format is only supported for the health command");
159 ExitCode::from(2)
160 }
161 }
162}
163
164#[must_use]
168pub fn print_duplication_report(
169 report: &DuplicationReport,
170 ctx: &ReportContext<'_>,
171 output: OutputFormat,
172) -> ExitCode {
173 match output {
174 OutputFormat::Human => {
175 human::print_duplication_human(report, ctx.root, ctx.elapsed, ctx.quiet);
176 ExitCode::SUCCESS
177 }
178 OutputFormat::Json => json::print_duplication_json(report, ctx.elapsed, ctx.explain),
179 OutputFormat::Compact => {
180 compact::print_duplication_compact(report, ctx.root);
181 ExitCode::SUCCESS
182 }
183 OutputFormat::Sarif => sarif::print_duplication_sarif(report, ctx.root),
184 OutputFormat::Markdown => {
185 markdown::print_duplication_markdown(report, ctx.root);
186 ExitCode::SUCCESS
187 }
188 OutputFormat::CodeClimate => codeclimate::print_duplication_codeclimate(report, ctx.root),
189 OutputFormat::Badge => {
190 eprintln!("Error: badge format is only supported for the health command");
191 ExitCode::from(2)
192 }
193 }
194}
195
196#[must_use]
200pub fn print_health_report(
201 report: &crate::health_types::HealthReport,
202 ctx: &ReportContext<'_>,
203 output: OutputFormat,
204) -> ExitCode {
205 match output {
206 OutputFormat::Human => {
207 human::print_health_human(report, ctx.root, ctx.elapsed, ctx.quiet);
208 ExitCode::SUCCESS
209 }
210 OutputFormat::Compact => {
211 compact::print_health_compact(report, ctx.root);
212 ExitCode::SUCCESS
213 }
214 OutputFormat::Markdown => {
215 markdown::print_health_markdown(report, ctx.root);
216 ExitCode::SUCCESS
217 }
218 OutputFormat::Sarif => sarif::print_health_sarif(report, ctx.root),
219 OutputFormat::Json => json::print_health_json(report, ctx.root, ctx.elapsed, ctx.explain),
220 OutputFormat::CodeClimate => codeclimate::print_health_codeclimate(report, ctx.root),
221 OutputFormat::Badge => badge::print_health_badge(report),
222 }
223}
224
225pub fn print_cross_reference_findings(
229 cross_ref: &fallow_core::cross_reference::CrossReferenceResult,
230 root: &Path,
231 quiet: bool,
232 output: OutputFormat,
233) {
234 human::print_cross_reference_findings(cross_ref, root, quiet, output);
235}
236
237pub fn print_export_trace(trace: &ExportTrace, format: OutputFormat) {
241 match format {
242 OutputFormat::Json => json::print_trace_json(trace),
243 _ => human::print_export_trace_human(trace),
244 }
245}
246
247pub fn print_file_trace(trace: &FileTrace, format: OutputFormat) {
249 match format {
250 OutputFormat::Json => json::print_trace_json(trace),
251 _ => human::print_file_trace_human(trace),
252 }
253}
254
255pub fn print_dependency_trace(trace: &DependencyTrace, format: OutputFormat) {
257 match format {
258 OutputFormat::Json => json::print_trace_json(trace),
259 _ => human::print_dependency_trace_human(trace),
260 }
261}
262
263pub fn print_clone_trace(trace: &CloneTrace, root: &Path, format: OutputFormat) {
265 match format {
266 OutputFormat::Json => json::print_trace_json(trace),
267 _ => human::print_clone_trace_human(trace, root),
268 }
269}
270
271pub fn print_performance(timings: &PipelineTimings, format: OutputFormat) {
274 match format {
275 OutputFormat::Json => match serde_json::to_string_pretty(timings) {
276 Ok(json) => eprintln!("{json}"),
277 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
278 },
279 _ => human::print_performance_human(timings),
280 }
281}
282
283#[allow(
286 unused_imports,
287 reason = "target-dependent: used in lib, unused in bin"
288)]
289pub use codeclimate::build_codeclimate;
290#[allow(
291 unused_imports,
292 reason = "target-dependent: used in lib, unused in bin"
293)]
294pub use codeclimate::build_duplication_codeclimate;
295#[allow(
296 unused_imports,
297 reason = "target-dependent: used in lib, unused in bin"
298)]
299pub use codeclimate::build_health_codeclimate;
300#[allow(
301 unused_imports,
302 reason = "target-dependent: used in lib, unused in bin"
303)]
304pub use compact::build_compact_lines;
305#[allow(
306 unused_imports,
307 reason = "target-dependent: used in lib, unused in bin"
308)]
309pub use json::build_json;
310#[allow(
311 unused_imports,
312 reason = "target-dependent: used in bin audit.rs, unused in lib"
313)]
314#[allow(
315 clippy::redundant_pub_crate,
316 reason = "pub(crate) deliberately limits visibility — report is pub but these are internal"
317)]
318pub(crate) use json::inject_dupes_actions;
319#[allow(
320 unused_imports,
321 reason = "target-dependent: used in bin audit.rs, unused in lib"
322)]
323#[allow(
324 clippy::redundant_pub_crate,
325 reason = "pub(crate) deliberately limits visibility — report is pub but these are internal"
326)]
327pub(crate) use json::inject_health_actions;
328#[allow(
329 unused_imports,
330 reason = "target-dependent: used in lib, unused in bin"
331)]
332pub use markdown::build_duplication_markdown;
333#[allow(
334 unused_imports,
335 reason = "target-dependent: used in lib, unused in bin"
336)]
337pub use markdown::build_health_markdown;
338#[allow(
339 unused_imports,
340 reason = "target-dependent: used in lib, unused in bin"
341)]
342pub use markdown::build_markdown;
343#[allow(
344 unused_imports,
345 reason = "target-dependent: used in lib, unused in bin"
346)]
347pub use sarif::build_health_sarif;
348#[allow(
349 unused_imports,
350 reason = "target-dependent: used in lib, unused in bin"
351)]
352pub use sarif::build_sarif;
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use std::path::PathBuf;
358
359 #[test]
362 fn normalize_uri_forward_slashes_unchanged() {
363 assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
364 }
365
366 #[test]
367 fn normalize_uri_backslashes_replaced() {
368 assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
369 }
370
371 #[test]
372 fn normalize_uri_mixed_slashes() {
373 assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
374 }
375
376 #[test]
377 fn normalize_uri_path_with_spaces() {
378 assert_eq!(
379 normalize_uri("src\\my folder\\file.ts"),
380 "src/my folder/file.ts"
381 );
382 }
383
384 #[test]
385 fn normalize_uri_empty_string() {
386 assert_eq!(normalize_uri(""), "");
387 }
388
389 #[test]
392 fn relative_path_strips_root_prefix() {
393 let root = Path::new("/project");
394 let path = Path::new("/project/src/utils.ts");
395 assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
396 }
397
398 #[test]
399 fn relative_path_returns_full_path_when_no_prefix() {
400 let root = Path::new("/other");
401 let path = Path::new("/project/src/utils.ts");
402 assert_eq!(relative_path(path, root), path);
403 }
404
405 #[test]
406 fn relative_path_at_root_returns_empty_or_file() {
407 let root = Path::new("/project");
408 let path = Path::new("/project/file.ts");
409 assert_eq!(relative_path(path, root), Path::new("file.ts"));
410 }
411
412 #[test]
413 fn relative_path_deeply_nested() {
414 let root = Path::new("/project");
415 let path = Path::new("/project/packages/ui/src/components/Button.tsx");
416 assert_eq!(
417 relative_path(path, root),
418 Path::new("packages/ui/src/components/Button.tsx")
419 );
420 }
421
422 #[test]
425 fn relative_uri_produces_forward_slash_path() {
426 let root = PathBuf::from("/project");
427 let path = root.join("src").join("utils.ts");
428 let uri = relative_uri(&path, &root);
429 assert_eq!(uri, "src/utils.ts");
430 }
431
432 #[test]
433 fn relative_uri_encodes_brackets() {
434 let root = PathBuf::from("/project");
435 let path = root.join("src/app/[...slug]/page.tsx");
436 let uri = relative_uri(&path, &root);
437 assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
438 }
439
440 #[test]
441 fn relative_uri_encodes_nested_dynamic_routes() {
442 let root = PathBuf::from("/project");
443 let path = root.join("src/app/[slug]/[id]/page.tsx");
444 let uri = relative_uri(&path, &root);
445 assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
446 }
447
448 #[test]
449 fn relative_uri_no_common_prefix_returns_full() {
450 let root = PathBuf::from("/other");
451 let path = PathBuf::from("/project/src/utils.ts");
452 let uri = relative_uri(&path, &root);
453 assert!(uri.contains("project"));
454 assert!(uri.contains("utils.ts"));
455 }
456
457 #[test]
460 fn severity_error_maps_to_level_error() {
461 assert!(matches!(severity_to_level(Severity::Error), Level::Error));
462 }
463
464 #[test]
465 fn severity_warn_maps_to_level_warn() {
466 assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
467 }
468
469 #[test]
470 fn severity_off_maps_to_level_info() {
471 assert!(matches!(severity_to_level(Severity::Off), Level::Info));
472 }
473
474 #[test]
477 fn normalize_uri_single_bracket_pair() {
478 assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
479 }
480
481 #[test]
482 fn normalize_uri_catch_all_route() {
483 assert_eq!(
484 normalize_uri("app/[...slug]/page.tsx"),
485 "app/%5B...slug%5D/page.tsx"
486 );
487 }
488
489 #[test]
490 fn normalize_uri_optional_catch_all_route() {
491 assert_eq!(
492 normalize_uri("app/[[...slug]]/page.tsx"),
493 "app/%5B%5B...slug%5D%5D/page.tsx"
494 );
495 }
496
497 #[test]
498 fn normalize_uri_multiple_dynamic_segments() {
499 assert_eq!(
500 normalize_uri("app/[lang]/posts/[id]"),
501 "app/%5Blang%5D/posts/%5Bid%5D"
502 );
503 }
504
505 #[test]
506 fn normalize_uri_no_special_chars() {
507 let plain = "src/components/Button.tsx";
508 assert_eq!(normalize_uri(plain), plain);
509 }
510
511 #[test]
512 fn normalize_uri_only_backslashes() {
513 assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
514 }
515
516 #[test]
519 fn relative_path_identical_paths_returns_empty() {
520 let root = Path::new("/project");
521 assert_eq!(relative_path(root, root), Path::new(""));
522 }
523
524 #[test]
525 fn relative_path_partial_name_match_not_stripped() {
526 let root = Path::new("/project");
529 let path = Path::new("/project-two/src/a.ts");
530 assert_eq!(relative_path(path, root), path);
531 }
532
533 #[test]
536 fn relative_uri_combines_stripping_and_encoding() {
537 let root = PathBuf::from("/project");
538 let path = root.join("src/app/[slug]/page.tsx");
539 let uri = relative_uri(&path, &root);
540 assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
542 assert!(!uri.starts_with('/'));
543 }
544
545 #[test]
546 fn relative_uri_at_root_file() {
547 let root = PathBuf::from("/project");
548 let path = root.join("index.ts");
549 assert_eq!(relative_uri(&path, &root), "index.ts");
550 }
551
552 #[test]
555 fn severity_to_level_is_const_evaluable() {
556 const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
558 const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
559 const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
560 assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
561 assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
562 assert!(matches!(LEVEL_FROM_OFF, Level::Info));
563 }
564
565 #[test]
568 fn level_is_copy() {
569 let level = severity_to_level(Severity::Error);
570 let copy = level;
571 assert!(matches!(level, Level::Error));
573 assert!(matches!(copy, Level::Error));
574 }
575
576 #[test]
579 fn elide_common_prefix_shared_dir() {
580 assert_eq!(
581 elide_common_prefix("src/components/A.tsx", "src/components/B.tsx"),
582 "B.tsx"
583 );
584 }
585
586 #[test]
587 fn elide_common_prefix_partial_shared() {
588 assert_eq!(
589 elide_common_prefix("src/components/A.tsx", "src/utils/B.tsx"),
590 "utils/B.tsx"
591 );
592 }
593
594 #[test]
595 fn elide_common_prefix_no_shared() {
596 assert_eq!(
597 elide_common_prefix("pkg-a/src/A.tsx", "pkg-b/src/B.tsx"),
598 "pkg-b/src/B.tsx"
599 );
600 }
601
602 #[test]
603 fn elide_common_prefix_identical_files() {
604 assert_eq!(elide_common_prefix("a/b/x.ts", "a/b/y.ts"), "y.ts");
606 }
607
608 #[test]
609 fn elide_common_prefix_no_dirs() {
610 assert_eq!(elide_common_prefix("foo.ts", "bar.ts"), "bar.ts");
611 }
612
613 #[test]
614 fn elide_common_prefix_deep_monorepo() {
615 assert_eq!(
616 elide_common_prefix(
617 "packages/rap/src/rap/components/SearchSelect/SearchSelect.tsx",
618 "packages/rap/src/rap/components/SearchSelect/SearchSelectItem.tsx"
619 ),
620 "SearchSelectItem.tsx"
621 );
622 }
623
624 #[test]
627 fn split_dir_filename_with_dir() {
628 let (dir, file) = split_dir_filename("src/utils/index.ts");
629 assert_eq!(dir, "src/utils/");
630 assert_eq!(file, "index.ts");
631 }
632
633 #[test]
634 fn split_dir_filename_no_dir() {
635 let (dir, file) = split_dir_filename("file.ts");
636 assert_eq!(dir, "");
637 assert_eq!(file, "file.ts");
638 }
639
640 #[test]
641 fn split_dir_filename_deeply_nested() {
642 let (dir, file) = split_dir_filename("a/b/c/d/e.ts");
643 assert_eq!(dir, "a/b/c/d/");
644 assert_eq!(file, "e.ts");
645 }
646
647 #[test]
648 fn split_dir_filename_trailing_slash() {
649 let (dir, file) = split_dir_filename("src/");
650 assert_eq!(dir, "src/");
651 assert_eq!(file, "");
652 }
653
654 #[test]
655 fn split_dir_filename_empty() {
656 let (dir, file) = split_dir_filename("");
657 assert_eq!(dir, "");
658 assert_eq!(file, "");
659 }
660
661 #[test]
664 fn plural_zero_is_plural() {
665 assert_eq!(plural(0), "s");
666 }
667
668 #[test]
669 fn plural_one_is_singular() {
670 assert_eq!(plural(1), "");
671 }
672
673 #[test]
674 fn plural_two_is_plural() {
675 assert_eq!(plural(2), "s");
676 }
677
678 #[test]
679 fn plural_large_number() {
680 assert_eq!(plural(999), "s");
681 }
682
683 #[test]
686 fn elide_common_prefix_empty_base() {
687 assert_eq!(elide_common_prefix("", "src/foo.ts"), "src/foo.ts");
688 }
689
690 #[test]
691 fn elide_common_prefix_empty_target() {
692 assert_eq!(elide_common_prefix("src/foo.ts", ""), "");
693 }
694
695 #[test]
696 fn elide_common_prefix_both_empty() {
697 assert_eq!(elide_common_prefix("", ""), "");
698 }
699
700 #[test]
701 fn elide_common_prefix_same_file_different_extension() {
702 assert_eq!(
704 elide_common_prefix("src/utils.ts", "src/utils.js"),
705 "utils.js"
706 );
707 }
708
709 #[test]
710 fn elide_common_prefix_partial_filename_match_not_stripped() {
711 assert_eq!(
713 elide_common_prefix("src/App.tsx", "src/AppUtils.tsx"),
714 "AppUtils.tsx"
715 );
716 }
717
718 #[test]
719 fn elide_common_prefix_identical_paths() {
720 assert_eq!(elide_common_prefix("src/foo.ts", "src/foo.ts"), "foo.ts");
721 }
722
723 #[test]
724 fn split_dir_filename_single_slash() {
725 let (dir, file) = split_dir_filename("/file.ts");
726 assert_eq!(dir, "/");
727 assert_eq!(file, "file.ts");
728 }
729
730 #[test]
731 fn emit_json_returns_success_for_valid_value() {
732 let value = serde_json::json!({"key": "value"});
733 let code = emit_json(&value, "test");
734 assert_eq!(code, ExitCode::SUCCESS);
735 }
736
737 mod proptests {
738 use super::*;
739 use proptest::prelude::*;
740
741 proptest! {
742 #[test]
744 fn split_dir_filename_reconstructs_path(path in "[a-zA-Z0-9_./\\-]{0,100}") {
745 let (dir, file) = split_dir_filename(&path);
746 let reconstructed = format!("{dir}{file}");
747 prop_assert_eq!(
748 reconstructed, path,
749 "dir+file should reconstruct the original path"
750 );
751 }
752
753 #[test]
755 fn plural_returns_empty_or_s(n: usize) {
756 let result = plural(n);
757 prop_assert!(
758 result.is_empty() || result == "s",
759 "plural should return \"\" or \"s\", got {:?}",
760 result
761 );
762 }
763
764 #[test]
766 fn plural_singular_only_for_one(n: usize) {
767 let result = plural(n);
768 if n == 1 {
769 prop_assert_eq!(result, "", "plural(1) should be empty");
770 } else {
771 prop_assert_eq!(result, "s", "plural({}) should be \"s\"", n);
772 }
773 }
774
775 #[test]
777 fn normalize_uri_no_backslashes(path in "[a-zA-Z0-9_.\\\\/ \\[\\]%-]{0,100}") {
778 let result = normalize_uri(&path);
779 prop_assert!(
780 !result.contains('\\'),
781 "Result should not contain backslashes: {result}"
782 );
783 }
784
785 #[test]
787 fn normalize_uri_encodes_all_brackets(path in "[a-zA-Z0-9_./\\[\\]%-]{0,80}") {
788 let result = normalize_uri(&path);
789 prop_assert!(
790 !result.contains('[') && !result.contains(']'),
791 "Result should not contain raw brackets: {result}"
792 );
793 }
794
795 #[test]
797 fn elide_common_prefix_returns_suffix_of_target(
798 base in "[a-zA-Z0-9_./]{0,50}",
799 target in "[a-zA-Z0-9_./]{0,50}",
800 ) {
801 let result = elide_common_prefix(&base, &target);
802 prop_assert!(
803 target.ends_with(result),
804 "Result {:?} should be a suffix of target {:?}",
805 result, target
806 );
807 }
808
809 #[test]
811 fn relative_path_never_panics(
812 root in "/[a-zA-Z0-9_/]{0,30}",
813 suffix in "[a-zA-Z0-9_./]{0,30}",
814 ) {
815 let root_path = Path::new(&root);
816 let full = PathBuf::from(format!("{root}/{suffix}"));
817 let _ = relative_path(&full, root_path);
818 }
819 }
820 }
821}