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
16fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
18 path.strip_prefix(root).unwrap_or(path)
19}
20
21fn 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
30fn relative_uri(path: &Path, root: &Path) -> String {
32 normalize_uri(&relative_path(path, root).display().to_string())
33}
34
35pub fn normalize_uri(path_str: &str) -> String {
40 path_str
41 .replace('\\', "/")
42 .replace('[', "%5B")
43 .replace(']', "%5D")
44}
45
46#[derive(Clone, Copy, Debug)]
48enum Level {
49 Warn,
50 Info,
51 Error,
52}
53
54const fn severity_to_level(s: Severity) -> Level {
55 match s {
56 Severity::Error => Level::Error,
57 Severity::Warn => Level::Warn,
58 Severity::Off => Level::Info,
60 }
61}
62
63pub fn print_results(
66 results: &AnalysisResults,
67 config: &ResolvedConfig,
68 elapsed: Duration,
69 quiet: bool,
70) -> ExitCode {
71 match config.output {
72 OutputFormat::Human => {
73 human::print_human(results, &config.root, &config.rules, elapsed, quiet);
74 ExitCode::SUCCESS
75 }
76 OutputFormat::Json => json::print_json(results, &config.root, elapsed),
77 OutputFormat::Compact => {
78 compact::print_compact(results, &config.root);
79 ExitCode::SUCCESS
80 }
81 OutputFormat::Sarif => sarif::print_sarif(results, &config.root, &config.rules),
82 OutputFormat::Markdown => {
83 markdown::print_markdown(results, &config.root);
84 ExitCode::SUCCESS
85 }
86 }
87}
88
89pub fn print_duplication_report(
93 report: &DuplicationReport,
94 config: &ResolvedConfig,
95 elapsed: Duration,
96 quiet: bool,
97 output: &OutputFormat,
98) -> ExitCode {
99 match output {
100 OutputFormat::Human => {
101 human::print_duplication_human(report, &config.root, elapsed, quiet);
102 ExitCode::SUCCESS
103 }
104 OutputFormat::Json => json::print_duplication_json(report, elapsed),
105 OutputFormat::Compact => {
106 compact::print_duplication_compact(report, &config.root);
107 ExitCode::SUCCESS
108 }
109 OutputFormat::Sarif => sarif::print_duplication_sarif(report, &config.root),
110 OutputFormat::Markdown => {
111 markdown::print_duplication_markdown(report, &config.root);
112 ExitCode::SUCCESS
113 }
114 }
115}
116
117pub fn print_health_report(
121 report: &crate::health_types::HealthReport,
122 config: &ResolvedConfig,
123 elapsed: Duration,
124 quiet: bool,
125 output: &OutputFormat,
126) -> ExitCode {
127 match output {
128 OutputFormat::Human => {
129 human::print_health_human(report, &config.root, elapsed, quiet);
130 ExitCode::SUCCESS
131 }
132 OutputFormat::Compact => {
133 compact::print_health_compact(report, &config.root);
134 ExitCode::SUCCESS
135 }
136 OutputFormat::Markdown => {
137 markdown::print_health_markdown(report, &config.root);
138 ExitCode::SUCCESS
139 }
140 OutputFormat::Sarif => sarif::print_health_sarif(report, &config.root),
141 OutputFormat::Json => json::print_health_json(report, &config.root, elapsed),
142 }
143}
144
145pub fn print_cross_reference_findings(
149 cross_ref: &fallow_core::cross_reference::CrossReferenceResult,
150 root: &Path,
151 quiet: bool,
152 output: &OutputFormat,
153) {
154 human::print_cross_reference_findings(cross_ref, root, quiet, output);
155}
156
157pub fn print_export_trace(trace: &ExportTrace, format: &OutputFormat) {
161 match format {
162 OutputFormat::Json => json::print_trace_json(trace),
163 _ => human::print_export_trace_human(trace),
164 }
165}
166
167pub fn print_file_trace(trace: &FileTrace, format: &OutputFormat) {
169 match format {
170 OutputFormat::Json => json::print_trace_json(trace),
171 _ => human::print_file_trace_human(trace),
172 }
173}
174
175pub fn print_dependency_trace(trace: &DependencyTrace, format: &OutputFormat) {
177 match format {
178 OutputFormat::Json => json::print_trace_json(trace),
179 _ => human::print_dependency_trace_human(trace),
180 }
181}
182
183pub fn print_clone_trace(trace: &CloneTrace, root: &Path, format: &OutputFormat) {
185 match format {
186 OutputFormat::Json => json::print_trace_json(trace),
187 _ => human::print_clone_trace_human(trace, root),
188 }
189}
190
191pub fn print_performance(timings: &PipelineTimings, format: &OutputFormat) {
194 match format {
195 OutputFormat::Json => match serde_json::to_string_pretty(timings) {
196 Ok(json) => eprintln!("{json}"),
197 Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
198 },
199 _ => human::print_performance_human(timings),
200 }
201}
202
203#[allow(unused_imports)]
205pub use compact::build_compact_lines;
206#[allow(unused_imports)]
207pub use json::build_json;
208#[allow(unused_imports)]
209pub use markdown::build_duplication_markdown;
210#[allow(unused_imports)]
211pub use markdown::build_health_markdown;
212#[allow(unused_imports)]
213pub use markdown::build_markdown;
214#[allow(unused_imports)]
215pub use sarif::build_health_sarif;
216#[allow(unused_imports)]
217pub use sarif::build_sarif;
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use std::path::PathBuf;
223
224 #[test]
227 fn normalize_uri_forward_slashes_unchanged() {
228 assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
229 }
230
231 #[test]
232 fn normalize_uri_backslashes_replaced() {
233 assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
234 }
235
236 #[test]
237 fn normalize_uri_mixed_slashes() {
238 assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
239 }
240
241 #[test]
242 fn normalize_uri_path_with_spaces() {
243 assert_eq!(
244 normalize_uri("src\\my folder\\file.ts"),
245 "src/my folder/file.ts"
246 );
247 }
248
249 #[test]
250 fn normalize_uri_empty_string() {
251 assert_eq!(normalize_uri(""), "");
252 }
253
254 #[test]
257 fn relative_path_strips_root_prefix() {
258 let root = Path::new("/project");
259 let path = Path::new("/project/src/utils.ts");
260 assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
261 }
262
263 #[test]
264 fn relative_path_returns_full_path_when_no_prefix() {
265 let root = Path::new("/other");
266 let path = Path::new("/project/src/utils.ts");
267 assert_eq!(relative_path(path, root), path);
268 }
269
270 #[test]
271 fn relative_path_at_root_returns_empty_or_file() {
272 let root = Path::new("/project");
273 let path = Path::new("/project/file.ts");
274 assert_eq!(relative_path(path, root), Path::new("file.ts"));
275 }
276
277 #[test]
278 fn relative_path_deeply_nested() {
279 let root = Path::new("/project");
280 let path = Path::new("/project/packages/ui/src/components/Button.tsx");
281 assert_eq!(
282 relative_path(path, root),
283 Path::new("packages/ui/src/components/Button.tsx")
284 );
285 }
286
287 #[test]
290 fn relative_uri_produces_forward_slash_path() {
291 let root = PathBuf::from("/project");
292 let path = root.join("src").join("utils.ts");
293 let uri = relative_uri(&path, &root);
294 assert_eq!(uri, "src/utils.ts");
295 }
296
297 #[test]
298 fn relative_uri_encodes_brackets() {
299 let root = PathBuf::from("/project");
300 let path = root.join("src/app/[...slug]/page.tsx");
301 let uri = relative_uri(&path, &root);
302 assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
303 }
304
305 #[test]
306 fn relative_uri_encodes_nested_dynamic_routes() {
307 let root = PathBuf::from("/project");
308 let path = root.join("src/app/[slug]/[id]/page.tsx");
309 let uri = relative_uri(&path, &root);
310 assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
311 }
312
313 #[test]
314 fn relative_uri_no_common_prefix_returns_full() {
315 let root = PathBuf::from("/other");
316 let path = PathBuf::from("/project/src/utils.ts");
317 let uri = relative_uri(&path, &root);
318 assert!(uri.contains("project"));
319 assert!(uri.contains("utils.ts"));
320 }
321
322 #[test]
325 fn severity_error_maps_to_level_error() {
326 assert!(matches!(severity_to_level(Severity::Error), Level::Error));
327 }
328
329 #[test]
330 fn severity_warn_maps_to_level_warn() {
331 assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
332 }
333
334 #[test]
335 fn severity_off_maps_to_level_info() {
336 assert!(matches!(severity_to_level(Severity::Off), Level::Info));
337 }
338
339 #[test]
342 fn normalize_uri_single_bracket_pair() {
343 assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
344 }
345
346 #[test]
347 fn normalize_uri_catch_all_route() {
348 assert_eq!(
349 normalize_uri("app/[...slug]/page.tsx"),
350 "app/%5B...slug%5D/page.tsx"
351 );
352 }
353
354 #[test]
355 fn normalize_uri_optional_catch_all_route() {
356 assert_eq!(
357 normalize_uri("app/[[...slug]]/page.tsx"),
358 "app/%5B%5B...slug%5D%5D/page.tsx"
359 );
360 }
361
362 #[test]
363 fn normalize_uri_multiple_dynamic_segments() {
364 assert_eq!(
365 normalize_uri("app/[lang]/posts/[id]"),
366 "app/%5Blang%5D/posts/%5Bid%5D"
367 );
368 }
369
370 #[test]
371 fn normalize_uri_no_special_chars() {
372 let plain = "src/components/Button.tsx";
373 assert_eq!(normalize_uri(plain), plain);
374 }
375
376 #[test]
377 fn normalize_uri_only_backslashes() {
378 assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
379 }
380
381 #[test]
384 fn relative_path_identical_paths_returns_empty() {
385 let root = Path::new("/project");
386 assert_eq!(relative_path(root, root), Path::new(""));
387 }
388
389 #[test]
390 fn relative_path_partial_name_match_not_stripped() {
391 let root = Path::new("/project");
394 let path = Path::new("/project-two/src/a.ts");
395 assert_eq!(relative_path(path, root), path);
396 }
397
398 #[test]
401 fn relative_uri_combines_stripping_and_encoding() {
402 let root = PathBuf::from("/project");
403 let path = root.join("src/app/[slug]/page.tsx");
404 let uri = relative_uri(&path, &root);
405 assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
407 assert!(!uri.starts_with('/'));
408 }
409
410 #[test]
411 fn relative_uri_at_root_file() {
412 let root = PathBuf::from("/project");
413 let path = root.join("index.ts");
414 assert_eq!(relative_uri(&path, &root), "index.ts");
415 }
416
417 #[test]
420 fn severity_to_level_is_const_evaluable() {
421 const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
423 const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
424 const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
425 assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
426 assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
427 assert!(matches!(LEVEL_FROM_OFF, Level::Info));
428 }
429
430 #[test]
433 fn level_is_copy() {
434 let level = severity_to_level(Severity::Error);
435 let copy = level;
436 assert!(matches!(level, Level::Error));
438 assert!(matches!(copy, Level::Error));
439 }
440}