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(
125 results: &AnalysisResults,
126 ctx: &ReportContext<'_>,
127 output: &OutputFormat,
128) -> ExitCode {
129 match output {
130 OutputFormat::Human => {
131 human::print_human(results, ctx.root, ctx.rules, ctx.elapsed, ctx.quiet);
132 ExitCode::SUCCESS
133 }
134 OutputFormat::Json => json::print_json(results, ctx.root, ctx.elapsed, ctx.explain),
135 OutputFormat::Compact => {
136 compact::print_compact(results, ctx.root);
137 ExitCode::SUCCESS
138 }
139 OutputFormat::Sarif => sarif::print_sarif(results, ctx.root, ctx.rules),
140 OutputFormat::Markdown => {
141 markdown::print_markdown(results, ctx.root);
142 ExitCode::SUCCESS
143 }
144 OutputFormat::CodeClimate => codeclimate::print_codeclimate(results, ctx.root, ctx.rules),
145 }
146}
147
148pub fn print_duplication_report(
152 report: &DuplicationReport,
153 ctx: &ReportContext<'_>,
154 output: &OutputFormat,
155) -> ExitCode {
156 match output {
157 OutputFormat::Human => {
158 human::print_duplication_human(report, ctx.root, ctx.elapsed, ctx.quiet);
159 ExitCode::SUCCESS
160 }
161 OutputFormat::Json => json::print_duplication_json(report, ctx.elapsed, ctx.explain),
162 OutputFormat::Compact => {
163 compact::print_duplication_compact(report, ctx.root);
164 ExitCode::SUCCESS
165 }
166 OutputFormat::Sarif => sarif::print_duplication_sarif(report, ctx.root),
167 OutputFormat::Markdown => {
168 markdown::print_duplication_markdown(report, ctx.root);
169 ExitCode::SUCCESS
170 }
171 OutputFormat::CodeClimate => codeclimate::print_duplication_codeclimate(report, ctx.root),
172 }
173}
174
175pub fn print_health_report(
179 report: &crate::health_types::HealthReport,
180 ctx: &ReportContext<'_>,
181 output: &OutputFormat,
182) -> ExitCode {
183 match output {
184 OutputFormat::Human => {
185 human::print_health_human(report, ctx.root, ctx.elapsed, ctx.quiet);
186 ExitCode::SUCCESS
187 }
188 OutputFormat::Compact => {
189 compact::print_health_compact(report, ctx.root);
190 ExitCode::SUCCESS
191 }
192 OutputFormat::Markdown => {
193 markdown::print_health_markdown(report, ctx.root);
194 ExitCode::SUCCESS
195 }
196 OutputFormat::Sarif => sarif::print_health_sarif(report, ctx.root),
197 OutputFormat::Json => json::print_health_json(report, ctx.root, ctx.elapsed, ctx.explain),
198 OutputFormat::CodeClimate => codeclimate::print_health_codeclimate(report, ctx.root),
199 }
200}
201
202pub fn print_cross_reference_findings(
206 cross_ref: &fallow_core::cross_reference::CrossReferenceResult,
207 root: &Path,
208 quiet: bool,
209 output: &OutputFormat,
210) {
211 human::print_cross_reference_findings(cross_ref, root, quiet, output);
212}
213
214pub fn print_export_trace(trace: &ExportTrace, format: &OutputFormat) {
218 match format {
219 OutputFormat::Json => json::print_trace_json(trace),
220 _ => human::print_export_trace_human(trace),
221 }
222}
223
224pub fn print_file_trace(trace: &FileTrace, format: &OutputFormat) {
226 match format {
227 OutputFormat::Json => json::print_trace_json(trace),
228 _ => human::print_file_trace_human(trace),
229 }
230}
231
232pub fn print_dependency_trace(trace: &DependencyTrace, format: &OutputFormat) {
234 match format {
235 OutputFormat::Json => json::print_trace_json(trace),
236 _ => human::print_dependency_trace_human(trace),
237 }
238}
239
240pub fn print_clone_trace(trace: &CloneTrace, root: &Path, format: &OutputFormat) {
242 match format {
243 OutputFormat::Json => json::print_trace_json(trace),
244 _ => human::print_clone_trace_human(trace, root),
245 }
246}
247
248pub fn print_performance(timings: &PipelineTimings, format: &OutputFormat) {
251 match format {
252 OutputFormat::Json => match serde_json::to_string_pretty(timings) {
253 Ok(json) => eprintln!("{json}"),
254 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
255 },
256 _ => human::print_performance_human(timings),
257 }
258}
259
260#[allow(unused_imports)]
264pub use codeclimate::build_codeclimate;
265#[allow(unused_imports)]
266pub use codeclimate::build_duplication_codeclimate;
267#[allow(unused_imports)]
268pub use codeclimate::build_health_codeclimate;
269#[allow(unused_imports)]
270pub use compact::build_compact_lines;
271#[allow(unused_imports)]
272pub use json::build_json;
273#[allow(unused_imports)]
274pub use markdown::build_duplication_markdown;
275#[allow(unused_imports)]
276pub use markdown::build_health_markdown;
277#[allow(unused_imports)]
278pub use markdown::build_markdown;
279#[allow(unused_imports)]
280pub use sarif::build_health_sarif;
281#[allow(unused_imports)]
282pub use sarif::build_sarif;
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use std::path::PathBuf;
288
289 #[test]
292 fn normalize_uri_forward_slashes_unchanged() {
293 assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
294 }
295
296 #[test]
297 fn normalize_uri_backslashes_replaced() {
298 assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
299 }
300
301 #[test]
302 fn normalize_uri_mixed_slashes() {
303 assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
304 }
305
306 #[test]
307 fn normalize_uri_path_with_spaces() {
308 assert_eq!(
309 normalize_uri("src\\my folder\\file.ts"),
310 "src/my folder/file.ts"
311 );
312 }
313
314 #[test]
315 fn normalize_uri_empty_string() {
316 assert_eq!(normalize_uri(""), "");
317 }
318
319 #[test]
322 fn relative_path_strips_root_prefix() {
323 let root = Path::new("/project");
324 let path = Path::new("/project/src/utils.ts");
325 assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
326 }
327
328 #[test]
329 fn relative_path_returns_full_path_when_no_prefix() {
330 let root = Path::new("/other");
331 let path = Path::new("/project/src/utils.ts");
332 assert_eq!(relative_path(path, root), path);
333 }
334
335 #[test]
336 fn relative_path_at_root_returns_empty_or_file() {
337 let root = Path::new("/project");
338 let path = Path::new("/project/file.ts");
339 assert_eq!(relative_path(path, root), Path::new("file.ts"));
340 }
341
342 #[test]
343 fn relative_path_deeply_nested() {
344 let root = Path::new("/project");
345 let path = Path::new("/project/packages/ui/src/components/Button.tsx");
346 assert_eq!(
347 relative_path(path, root),
348 Path::new("packages/ui/src/components/Button.tsx")
349 );
350 }
351
352 #[test]
355 fn relative_uri_produces_forward_slash_path() {
356 let root = PathBuf::from("/project");
357 let path = root.join("src").join("utils.ts");
358 let uri = relative_uri(&path, &root);
359 assert_eq!(uri, "src/utils.ts");
360 }
361
362 #[test]
363 fn relative_uri_encodes_brackets() {
364 let root = PathBuf::from("/project");
365 let path = root.join("src/app/[...slug]/page.tsx");
366 let uri = relative_uri(&path, &root);
367 assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
368 }
369
370 #[test]
371 fn relative_uri_encodes_nested_dynamic_routes() {
372 let root = PathBuf::from("/project");
373 let path = root.join("src/app/[slug]/[id]/page.tsx");
374 let uri = relative_uri(&path, &root);
375 assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
376 }
377
378 #[test]
379 fn relative_uri_no_common_prefix_returns_full() {
380 let root = PathBuf::from("/other");
381 let path = PathBuf::from("/project/src/utils.ts");
382 let uri = relative_uri(&path, &root);
383 assert!(uri.contains("project"));
384 assert!(uri.contains("utils.ts"));
385 }
386
387 #[test]
390 fn severity_error_maps_to_level_error() {
391 assert!(matches!(severity_to_level(Severity::Error), Level::Error));
392 }
393
394 #[test]
395 fn severity_warn_maps_to_level_warn() {
396 assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
397 }
398
399 #[test]
400 fn severity_off_maps_to_level_info() {
401 assert!(matches!(severity_to_level(Severity::Off), Level::Info));
402 }
403
404 #[test]
407 fn normalize_uri_single_bracket_pair() {
408 assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
409 }
410
411 #[test]
412 fn normalize_uri_catch_all_route() {
413 assert_eq!(
414 normalize_uri("app/[...slug]/page.tsx"),
415 "app/%5B...slug%5D/page.tsx"
416 );
417 }
418
419 #[test]
420 fn normalize_uri_optional_catch_all_route() {
421 assert_eq!(
422 normalize_uri("app/[[...slug]]/page.tsx"),
423 "app/%5B%5B...slug%5D%5D/page.tsx"
424 );
425 }
426
427 #[test]
428 fn normalize_uri_multiple_dynamic_segments() {
429 assert_eq!(
430 normalize_uri("app/[lang]/posts/[id]"),
431 "app/%5Blang%5D/posts/%5Bid%5D"
432 );
433 }
434
435 #[test]
436 fn normalize_uri_no_special_chars() {
437 let plain = "src/components/Button.tsx";
438 assert_eq!(normalize_uri(plain), plain);
439 }
440
441 #[test]
442 fn normalize_uri_only_backslashes() {
443 assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
444 }
445
446 #[test]
449 fn relative_path_identical_paths_returns_empty() {
450 let root = Path::new("/project");
451 assert_eq!(relative_path(root, root), Path::new(""));
452 }
453
454 #[test]
455 fn relative_path_partial_name_match_not_stripped() {
456 let root = Path::new("/project");
459 let path = Path::new("/project-two/src/a.ts");
460 assert_eq!(relative_path(path, root), path);
461 }
462
463 #[test]
466 fn relative_uri_combines_stripping_and_encoding() {
467 let root = PathBuf::from("/project");
468 let path = root.join("src/app/[slug]/page.tsx");
469 let uri = relative_uri(&path, &root);
470 assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
472 assert!(!uri.starts_with('/'));
473 }
474
475 #[test]
476 fn relative_uri_at_root_file() {
477 let root = PathBuf::from("/project");
478 let path = root.join("index.ts");
479 assert_eq!(relative_uri(&path, &root), "index.ts");
480 }
481
482 #[test]
485 fn severity_to_level_is_const_evaluable() {
486 const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
488 const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
489 const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
490 assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
491 assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
492 assert!(matches!(LEVEL_FROM_OFF, Level::Info));
493 }
494
495 #[test]
498 fn level_is_copy() {
499 let level = severity_to_level(Severity::Error);
500 let copy = level;
501 assert!(matches!(level, Level::Error));
503 assert!(matches!(copy, Level::Error));
504 }
505
506 #[test]
509 fn elide_common_prefix_shared_dir() {
510 assert_eq!(
511 elide_common_prefix("src/components/A.tsx", "src/components/B.tsx"),
512 "B.tsx"
513 );
514 }
515
516 #[test]
517 fn elide_common_prefix_partial_shared() {
518 assert_eq!(
519 elide_common_prefix("src/components/A.tsx", "src/utils/B.tsx"),
520 "utils/B.tsx"
521 );
522 }
523
524 #[test]
525 fn elide_common_prefix_no_shared() {
526 assert_eq!(
527 elide_common_prefix("pkg-a/src/A.tsx", "pkg-b/src/B.tsx"),
528 "pkg-b/src/B.tsx"
529 );
530 }
531
532 #[test]
533 fn elide_common_prefix_identical_files() {
534 assert_eq!(elide_common_prefix("a/b/x.ts", "a/b/y.ts"), "y.ts");
536 }
537
538 #[test]
539 fn elide_common_prefix_no_dirs() {
540 assert_eq!(elide_common_prefix("foo.ts", "bar.ts"), "bar.ts");
541 }
542
543 #[test]
544 fn elide_common_prefix_deep_monorepo() {
545 assert_eq!(
546 elide_common_prefix(
547 "packages/rap/src/rap/components/SearchSelect/SearchSelect.tsx",
548 "packages/rap/src/rap/components/SearchSelect/SearchSelectItem.tsx"
549 ),
550 "SearchSelectItem.tsx"
551 );
552 }
553
554 #[test]
557 fn split_dir_filename_with_dir() {
558 let (dir, file) = split_dir_filename("src/utils/index.ts");
559 assert_eq!(dir, "src/utils/");
560 assert_eq!(file, "index.ts");
561 }
562
563 #[test]
564 fn split_dir_filename_no_dir() {
565 let (dir, file) = split_dir_filename("file.ts");
566 assert_eq!(dir, "");
567 assert_eq!(file, "file.ts");
568 }
569
570 #[test]
571 fn split_dir_filename_deeply_nested() {
572 let (dir, file) = split_dir_filename("a/b/c/d/e.ts");
573 assert_eq!(dir, "a/b/c/d/");
574 assert_eq!(file, "e.ts");
575 }
576
577 #[test]
578 fn split_dir_filename_trailing_slash() {
579 let (dir, file) = split_dir_filename("src/");
580 assert_eq!(dir, "src/");
581 assert_eq!(file, "");
582 }
583
584 #[test]
585 fn split_dir_filename_empty() {
586 let (dir, file) = split_dir_filename("");
587 assert_eq!(dir, "");
588 assert_eq!(file, "");
589 }
590
591 #[test]
594 fn plural_zero_is_plural() {
595 assert_eq!(plural(0), "s");
596 }
597
598 #[test]
599 fn plural_one_is_singular() {
600 assert_eq!(plural(1), "");
601 }
602
603 #[test]
604 fn plural_two_is_plural() {
605 assert_eq!(plural(2), "s");
606 }
607
608 #[test]
609 fn plural_large_number() {
610 assert_eq!(plural(999), "s");
611 }
612
613 #[test]
616 fn elide_common_prefix_empty_base() {
617 assert_eq!(elide_common_prefix("", "src/foo.ts"), "src/foo.ts");
618 }
619
620 #[test]
621 fn elide_common_prefix_empty_target() {
622 assert_eq!(elide_common_prefix("src/foo.ts", ""), "");
623 }
624
625 #[test]
626 fn elide_common_prefix_both_empty() {
627 assert_eq!(elide_common_prefix("", ""), "");
628 }
629
630 #[test]
631 fn elide_common_prefix_same_file_different_extension() {
632 assert_eq!(
634 elide_common_prefix("src/utils.ts", "src/utils.js"),
635 "utils.js"
636 );
637 }
638
639 #[test]
640 fn elide_common_prefix_partial_filename_match_not_stripped() {
641 assert_eq!(
643 elide_common_prefix("src/App.tsx", "src/AppUtils.tsx"),
644 "AppUtils.tsx"
645 );
646 }
647
648 #[test]
649 fn elide_common_prefix_identical_paths() {
650 assert_eq!(elide_common_prefix("src/foo.ts", "src/foo.ts"), "foo.ts");
651 }
652
653 #[test]
654 fn split_dir_filename_single_slash() {
655 let (dir, file) = split_dir_filename("/file.ts");
656 assert_eq!(dir, "/");
657 assert_eq!(file, "file.ts");
658 }
659
660 #[test]
661 fn emit_json_returns_success_for_valid_value() {
662 let value = serde_json::json!({"key": "value"});
663 let code = emit_json(&value, "test");
664 assert_eq!(code, ExitCode::SUCCESS);
665 }
666
667 mod proptests {
668 use super::*;
669 use proptest::prelude::*;
670
671 proptest! {
672 #[test]
674 fn split_dir_filename_reconstructs_path(path in "[a-zA-Z0-9_./\\-]{0,100}") {
675 let (dir, file) = split_dir_filename(&path);
676 let reconstructed = format!("{dir}{file}");
677 prop_assert_eq!(
678 reconstructed, path,
679 "dir+file should reconstruct the original path"
680 );
681 }
682
683 #[test]
685 fn plural_returns_empty_or_s(n: usize) {
686 let result = plural(n);
687 prop_assert!(
688 result.is_empty() || result == "s",
689 "plural should return \"\" or \"s\", got {:?}",
690 result
691 );
692 }
693
694 #[test]
696 fn plural_singular_only_for_one(n: usize) {
697 let result = plural(n);
698 if n == 1 {
699 prop_assert_eq!(result, "", "plural(1) should be empty");
700 } else {
701 prop_assert_eq!(result, "s", "plural({}) should be \"s\"", n);
702 }
703 }
704
705 #[test]
707 fn normalize_uri_no_backslashes(path in "[a-zA-Z0-9_.\\\\/ \\[\\]%-]{0,100}") {
708 let result = normalize_uri(&path);
709 prop_assert!(
710 !result.contains('\\'),
711 "Result should not contain backslashes: {result}"
712 );
713 }
714
715 #[test]
717 fn normalize_uri_encodes_all_brackets(path in "[a-zA-Z0-9_./\\[\\]%-]{0,80}") {
718 let result = normalize_uri(&path);
719 prop_assert!(
720 !result.contains('[') && !result.contains(']'),
721 "Result should not contain raw brackets: {result}"
722 );
723 }
724
725 #[test]
727 fn elide_common_prefix_returns_suffix_of_target(
728 base in "[a-zA-Z0-9_./]{0,50}",
729 target in "[a-zA-Z0-9_./]{0,50}",
730 ) {
731 let result = elide_common_prefix(&base, &target);
732 prop_assert!(
733 target.ends_with(result),
734 "Result {:?} should be a suffix of target {:?}",
735 result, target
736 );
737 }
738
739 #[test]
741 fn relative_path_never_panics(
742 root in "/[a-zA-Z0-9_/]{0,30}",
743 suffix in "[a-zA-Z0-9_./]{0,30}",
744 ) {
745 let root_path = Path::new(&root);
746 let full = PathBuf::from(format!("{root}/{suffix}"));
747 let _ = relative_path(&full, root_path);
748 }
749 }
750 }
751}