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