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