Skip to main content

fallow_cli/report/
mod.rs

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
17/// Strip the project root prefix from a path for display, falling back to the full path.
18pub fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
19    path.strip_prefix(root).unwrap_or(path)
20}
21
22/// Split a path string into (directory, filename) for display.
23/// Directory includes the trailing `/`. If no directory, returns `("", filename)`.
24pub 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
31/// Elide the common directory prefix between a base path and a target path.
32/// Only strips complete directory segments (never partial filenames).
33/// Returns the remaining suffix of `target`.
34///
35/// Example: `elide_common_prefix("a/b/c/foo.ts", "a/b/d/bar.ts")` → `"d/bar.ts"`
36pub 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
53/// Compute a SARIF-compatible relative URI from an absolute path and project root.
54fn relative_uri(path: &Path, root: &Path) -> String {
55    normalize_uri(&relative_path(path, root).display().to_string())
56}
57
58/// Normalize a path string to a valid URI: forward slashes and percent-encoded brackets.
59///
60/// Brackets (`[`, `]`) are not valid in URI path segments per RFC 3986 and cause
61/// SARIF validation warnings (e.g., Next.js dynamic routes like `[slug]`).
62pub fn normalize_uri(path_str: &str) -> String {
63    path_str
64        .replace('\\', "/")
65        .replace('[', "%5B")
66        .replace(']', "%5D")
67}
68
69/// Severity level for human-readable output.
70#[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        // Off issues are filtered before reporting; fall back to Info.
82        Severity::Off => Level::Info,
83    }
84}
85
86/// Print analysis results in the configured format.
87/// Returns exit code 2 if serialization fails, SUCCESS otherwise.
88pub 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
116// ── Duplication report ────────────────────────────────────────────
117
118/// Print duplication analysis results in the configured format.
119pub 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
148// ── Health / complexity report ─────────────────────────────────────
149
150/// Print health (complexity) analysis results in the configured format.
151pub 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
178/// Print cross-reference findings (duplicated code that is also dead code).
179///
180/// Only emits output in human format to avoid corrupting structured JSON/SARIF output.
181pub 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
190// ── Trace output ──────────────────────────────────────────────────
191
192/// Print export trace results.
193pub 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
200/// Print file trace results.
201pub 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
208/// Print dependency trace results.
209pub 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
216/// Print clone trace results.
217pub 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
224/// Print pipeline performance timings.
225/// In JSON mode, outputs to stderr to avoid polluting the JSON analysis output on stdout.
226pub 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// Re-exported for snapshot testing via the lib target.
237// Uses #[allow] instead of #[expect] because unused_imports is target-dependent
238// (used in lib target, unused in bin target — #[expect] would be unfulfilled in one).
239#[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    // ── normalize_uri ────────────────────────────────────────────────
266
267    #[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    // ── relative_path ────────────────────────────────────────────────
296
297    #[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    // ── relative_uri ─────────────────────────────────────────────────
329
330    #[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    // ── severity_to_level ────────────────────────────────────────────
364
365    #[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    // ── normalize_uri bracket encoding ──────────────────────────────
381
382    #[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    // ── relative_path edge cases ────────────────────────────────────
423
424    #[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        // "/project-two/src/a.ts" should NOT strip "/project" because
433        // "/project" is not a proper prefix of "/project-two".
434        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    // ── relative_uri edge cases ─────────────────────────────────────
440
441    #[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        // Should both strip the prefix AND encode brackets.
447        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    // ── severity_to_level exhaustiveness ────────────────────────────
459
460    #[test]
461    fn severity_to_level_is_const_evaluable() {
462        // Verify the function can be used in const context.
463        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    // ── Level is Copy ───────────────────────────────────────────────
472
473    #[test]
474    fn level_is_copy() {
475        let level = severity_to_level(Severity::Error);
476        let copy = level;
477        // Both should still be usable (Copy semantics).
478        assert!(matches!(level, Level::Error));
479        assert!(matches!(copy, Level::Error));
480    }
481
482    // ── elide_common_prefix ─────────────────────────────────────────
483
484    #[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        // Same dir, different file
511        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}