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}