1mod codeclimate;
2mod compact;
3mod human;
4mod json;
5mod markdown;
6mod sarif;
7
8use std::path::Path;
9use std::process::ExitCode;
10use std::time::Duration;
11
12use fallow_config::{OutputFormat, ResolvedConfig, Severity};
13use fallow_core::duplicates::DuplicationReport;
14use fallow_core::results::AnalysisResults;
15use fallow_core::trace::{CloneTrace, DependencyTrace, ExportTrace, FileTrace, PipelineTimings};
16
17pub fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
19 path.strip_prefix(root).unwrap_or(path)
20}
21
22pub fn split_dir_filename(path: &str) -> (&str, &str) {
25 match path.rfind('/') {
26 Some(pos) => (&path[..=pos], &path[pos + 1..]),
27 None => ("", path),
28 }
29}
30
31pub fn elide_common_prefix<'a>(base: &str, target: &'a str) -> &'a str {
37 let mut last_sep = 0;
38 for (i, (a, b)) in base.bytes().zip(target.bytes()).enumerate() {
39 if a != b {
40 break;
41 }
42 if a == b'/' {
43 last_sep = i + 1;
44 }
45 }
46 if last_sep > 0 && last_sep <= target.len() {
47 &target[last_sep..]
48 } else {
49 target
50 }
51}
52
53fn relative_uri(path: &Path, root: &Path) -> String {
55 normalize_uri(&relative_path(path, root).display().to_string())
56}
57
58pub fn normalize_uri(path_str: &str) -> String {
63 path_str
64 .replace('\\', "/")
65 .replace('[', "%5B")
66 .replace(']', "%5D")
67}
68
69#[derive(Clone, Copy, Debug)]
71pub enum Level {
72 Warn,
73 Info,
74 Error,
75}
76
77pub const fn severity_to_level(s: Severity) -> Level {
78 match s {
79 Severity::Error => Level::Error,
80 Severity::Warn => Level::Warn,
81 Severity::Off => Level::Info,
83 }
84}
85
86pub fn print_results(
89 results: &AnalysisResults,
90 config: &ResolvedConfig,
91 elapsed: Duration,
92 quiet: bool,
93 explain: bool,
94) -> ExitCode {
95 match config.output {
96 OutputFormat::Human => {
97 human::print_human(results, &config.root, &config.rules, elapsed, quiet);
98 ExitCode::SUCCESS
99 }
100 OutputFormat::Json => json::print_json(results, &config.root, elapsed, explain),
101 OutputFormat::Compact => {
102 compact::print_compact(results, &config.root);
103 ExitCode::SUCCESS
104 }
105 OutputFormat::Sarif => sarif::print_sarif(results, &config.root, &config.rules),
106 OutputFormat::Markdown => {
107 markdown::print_markdown(results, &config.root);
108 ExitCode::SUCCESS
109 }
110 OutputFormat::CodeClimate => {
111 codeclimate::print_codeclimate(results, &config.root, &config.rules)
112 }
113 }
114}
115
116pub fn print_duplication_report(
120 report: &DuplicationReport,
121 config: &ResolvedConfig,
122 elapsed: Duration,
123 quiet: bool,
124 output: &OutputFormat,
125 explain: bool,
126) -> ExitCode {
127 match output {
128 OutputFormat::Human => {
129 human::print_duplication_human(report, &config.root, elapsed, quiet);
130 ExitCode::SUCCESS
131 }
132 OutputFormat::Json => json::print_duplication_json(report, elapsed, explain),
133 OutputFormat::Compact => {
134 compact::print_duplication_compact(report, &config.root);
135 ExitCode::SUCCESS
136 }
137 OutputFormat::Sarif => sarif::print_duplication_sarif(report, &config.root),
138 OutputFormat::Markdown => {
139 markdown::print_duplication_markdown(report, &config.root);
140 ExitCode::SUCCESS
141 }
142 OutputFormat::CodeClimate => {
143 codeclimate::print_duplication_codeclimate(report, &config.root)
144 }
145 }
146}
147
148pub fn print_health_report(
152 report: &crate::health_types::HealthReport,
153 config: &ResolvedConfig,
154 elapsed: Duration,
155 quiet: bool,
156 output: &OutputFormat,
157 explain: bool,
158) -> ExitCode {
159 match output {
160 OutputFormat::Human => {
161 human::print_health_human(report, &config.root, elapsed, quiet);
162 ExitCode::SUCCESS
163 }
164 OutputFormat::Compact => {
165 compact::print_health_compact(report, &config.root);
166 ExitCode::SUCCESS
167 }
168 OutputFormat::Markdown => {
169 markdown::print_health_markdown(report, &config.root);
170 ExitCode::SUCCESS
171 }
172 OutputFormat::Sarif => sarif::print_health_sarif(report, &config.root),
173 OutputFormat::Json => json::print_health_json(report, &config.root, elapsed, explain),
174 OutputFormat::CodeClimate => codeclimate::print_health_codeclimate(report, &config.root),
175 }
176}
177
178pub fn print_cross_reference_findings(
182 cross_ref: &fallow_core::cross_reference::CrossReferenceResult,
183 root: &Path,
184 quiet: bool,
185 output: &OutputFormat,
186) {
187 human::print_cross_reference_findings(cross_ref, root, quiet, output);
188}
189
190pub fn print_export_trace(trace: &ExportTrace, format: &OutputFormat) {
194 match format {
195 OutputFormat::Json => json::print_trace_json(trace),
196 _ => human::print_export_trace_human(trace),
197 }
198}
199
200pub fn print_file_trace(trace: &FileTrace, format: &OutputFormat) {
202 match format {
203 OutputFormat::Json => json::print_trace_json(trace),
204 _ => human::print_file_trace_human(trace),
205 }
206}
207
208pub fn print_dependency_trace(trace: &DependencyTrace, format: &OutputFormat) {
210 match format {
211 OutputFormat::Json => json::print_trace_json(trace),
212 _ => human::print_dependency_trace_human(trace),
213 }
214}
215
216pub fn print_clone_trace(trace: &CloneTrace, root: &Path, format: &OutputFormat) {
218 match format {
219 OutputFormat::Json => json::print_trace_json(trace),
220 _ => human::print_clone_trace_human(trace, root),
221 }
222}
223
224pub fn print_performance(timings: &PipelineTimings, format: &OutputFormat) {
227 match format {
228 OutputFormat::Json => match serde_json::to_string_pretty(timings) {
229 Ok(json) => eprintln!("{json}"),
230 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
231 },
232 _ => human::print_performance_human(timings),
233 }
234}
235
236#[allow(unused_imports)]
240pub use codeclimate::build_codeclimate;
241#[allow(unused_imports)]
242pub use codeclimate::build_duplication_codeclimate;
243#[allow(unused_imports)]
244pub use codeclimate::build_health_codeclimate;
245#[allow(unused_imports)]
246pub use compact::build_compact_lines;
247#[allow(unused_imports)]
248pub use json::build_json;
249#[allow(unused_imports)]
250pub use markdown::build_duplication_markdown;
251#[allow(unused_imports)]
252pub use markdown::build_health_markdown;
253#[allow(unused_imports)]
254pub use markdown::build_markdown;
255#[allow(unused_imports)]
256pub use sarif::build_health_sarif;
257#[allow(unused_imports)]
258pub use sarif::build_sarif;
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use std::path::PathBuf;
264
265 #[test]
268 fn normalize_uri_forward_slashes_unchanged() {
269 assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
270 }
271
272 #[test]
273 fn normalize_uri_backslashes_replaced() {
274 assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
275 }
276
277 #[test]
278 fn normalize_uri_mixed_slashes() {
279 assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
280 }
281
282 #[test]
283 fn normalize_uri_path_with_spaces() {
284 assert_eq!(
285 normalize_uri("src\\my folder\\file.ts"),
286 "src/my folder/file.ts"
287 );
288 }
289
290 #[test]
291 fn normalize_uri_empty_string() {
292 assert_eq!(normalize_uri(""), "");
293 }
294
295 #[test]
298 fn relative_path_strips_root_prefix() {
299 let root = Path::new("/project");
300 let path = Path::new("/project/src/utils.ts");
301 assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
302 }
303
304 #[test]
305 fn relative_path_returns_full_path_when_no_prefix() {
306 let root = Path::new("/other");
307 let path = Path::new("/project/src/utils.ts");
308 assert_eq!(relative_path(path, root), path);
309 }
310
311 #[test]
312 fn relative_path_at_root_returns_empty_or_file() {
313 let root = Path::new("/project");
314 let path = Path::new("/project/file.ts");
315 assert_eq!(relative_path(path, root), Path::new("file.ts"));
316 }
317
318 #[test]
319 fn relative_path_deeply_nested() {
320 let root = Path::new("/project");
321 let path = Path::new("/project/packages/ui/src/components/Button.tsx");
322 assert_eq!(
323 relative_path(path, root),
324 Path::new("packages/ui/src/components/Button.tsx")
325 );
326 }
327
328 #[test]
331 fn relative_uri_produces_forward_slash_path() {
332 let root = PathBuf::from("/project");
333 let path = root.join("src").join("utils.ts");
334 let uri = relative_uri(&path, &root);
335 assert_eq!(uri, "src/utils.ts");
336 }
337
338 #[test]
339 fn relative_uri_encodes_brackets() {
340 let root = PathBuf::from("/project");
341 let path = root.join("src/app/[...slug]/page.tsx");
342 let uri = relative_uri(&path, &root);
343 assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
344 }
345
346 #[test]
347 fn relative_uri_encodes_nested_dynamic_routes() {
348 let root = PathBuf::from("/project");
349 let path = root.join("src/app/[slug]/[id]/page.tsx");
350 let uri = relative_uri(&path, &root);
351 assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
352 }
353
354 #[test]
355 fn relative_uri_no_common_prefix_returns_full() {
356 let root = PathBuf::from("/other");
357 let path = PathBuf::from("/project/src/utils.ts");
358 let uri = relative_uri(&path, &root);
359 assert!(uri.contains("project"));
360 assert!(uri.contains("utils.ts"));
361 }
362
363 #[test]
366 fn severity_error_maps_to_level_error() {
367 assert!(matches!(severity_to_level(Severity::Error), Level::Error));
368 }
369
370 #[test]
371 fn severity_warn_maps_to_level_warn() {
372 assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
373 }
374
375 #[test]
376 fn severity_off_maps_to_level_info() {
377 assert!(matches!(severity_to_level(Severity::Off), Level::Info));
378 }
379
380 #[test]
383 fn normalize_uri_single_bracket_pair() {
384 assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
385 }
386
387 #[test]
388 fn normalize_uri_catch_all_route() {
389 assert_eq!(
390 normalize_uri("app/[...slug]/page.tsx"),
391 "app/%5B...slug%5D/page.tsx"
392 );
393 }
394
395 #[test]
396 fn normalize_uri_optional_catch_all_route() {
397 assert_eq!(
398 normalize_uri("app/[[...slug]]/page.tsx"),
399 "app/%5B%5B...slug%5D%5D/page.tsx"
400 );
401 }
402
403 #[test]
404 fn normalize_uri_multiple_dynamic_segments() {
405 assert_eq!(
406 normalize_uri("app/[lang]/posts/[id]"),
407 "app/%5Blang%5D/posts/%5Bid%5D"
408 );
409 }
410
411 #[test]
412 fn normalize_uri_no_special_chars() {
413 let plain = "src/components/Button.tsx";
414 assert_eq!(normalize_uri(plain), plain);
415 }
416
417 #[test]
418 fn normalize_uri_only_backslashes() {
419 assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
420 }
421
422 #[test]
425 fn relative_path_identical_paths_returns_empty() {
426 let root = Path::new("/project");
427 assert_eq!(relative_path(root, root), Path::new(""));
428 }
429
430 #[test]
431 fn relative_path_partial_name_match_not_stripped() {
432 let root = Path::new("/project");
435 let path = Path::new("/project-two/src/a.ts");
436 assert_eq!(relative_path(path, root), path);
437 }
438
439 #[test]
442 fn relative_uri_combines_stripping_and_encoding() {
443 let root = PathBuf::from("/project");
444 let path = root.join("src/app/[slug]/page.tsx");
445 let uri = relative_uri(&path, &root);
446 assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
448 assert!(!uri.starts_with('/'));
449 }
450
451 #[test]
452 fn relative_uri_at_root_file() {
453 let root = PathBuf::from("/project");
454 let path = root.join("index.ts");
455 assert_eq!(relative_uri(&path, &root), "index.ts");
456 }
457
458 #[test]
461 fn severity_to_level_is_const_evaluable() {
462 const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
464 const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
465 const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
466 assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
467 assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
468 assert!(matches!(LEVEL_FROM_OFF, Level::Info));
469 }
470
471 #[test]
474 fn level_is_copy() {
475 let level = severity_to_level(Severity::Error);
476 let copy = level;
477 assert!(matches!(level, Level::Error));
479 assert!(matches!(copy, Level::Error));
480 }
481
482 #[test]
485 fn elide_common_prefix_shared_dir() {
486 assert_eq!(
487 elide_common_prefix("src/components/A.tsx", "src/components/B.tsx"),
488 "B.tsx"
489 );
490 }
491
492 #[test]
493 fn elide_common_prefix_partial_shared() {
494 assert_eq!(
495 elide_common_prefix("src/components/A.tsx", "src/utils/B.tsx"),
496 "utils/B.tsx"
497 );
498 }
499
500 #[test]
501 fn elide_common_prefix_no_shared() {
502 assert_eq!(
503 elide_common_prefix("pkg-a/src/A.tsx", "pkg-b/src/B.tsx"),
504 "pkg-b/src/B.tsx"
505 );
506 }
507
508 #[test]
509 fn elide_common_prefix_identical_files() {
510 assert_eq!(elide_common_prefix("a/b/x.ts", "a/b/y.ts"), "y.ts");
512 }
513
514 #[test]
515 fn elide_common_prefix_no_dirs() {
516 assert_eq!(elide_common_prefix("foo.ts", "bar.ts"), "bar.ts");
517 }
518
519 #[test]
520 fn elide_common_prefix_deep_monorepo() {
521 assert_eq!(
522 elide_common_prefix(
523 "packages/rap/src/rap/components/SearchSelect/SearchSelect.tsx",
524 "packages/rap/src/rap/components/SearchSelect/SearchSelectItem.tsx"
525 ),
526 "SearchSelectItem.tsx"
527 );
528 }
529}