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