Skip to main content

fallow_cli/report/
mod.rs

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
19/// Shared context for all report dispatch functions.
20///
21/// Bundles the common parameters that every format renderer needs,
22/// replacing per-parameter threading through the dispatch match arms.
23pub 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
31/// Strip the project root prefix from a path for display, falling back to the full path.
32pub fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
33    path.strip_prefix(root).unwrap_or(path)
34}
35
36/// Split a path string into (directory, filename) for display.
37/// Directory includes the trailing `/`. If no directory, returns `("", filename)`.
38pub 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
45/// Return `"s"` for plural or `""` for singular.
46pub fn plural(n: usize) -> &'static str {
47    if n == 1 { "" } else { "s" }
48}
49
50/// Serialize a JSON value to pretty-printed stdout, returning the appropriate exit code.
51///
52/// On success prints the JSON and returns `ExitCode::SUCCESS`.
53/// On serialization failure prints an error to stderr and returns exit code 2.
54pub 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
67/// Elide the common directory prefix between a base path and a target path.
68/// Only strips complete directory segments (never partial filenames).
69/// Returns the remaining suffix of `target`.
70///
71/// Example: `elide_common_prefix("a/b/c/foo.ts", "a/b/d/bar.ts")` → `"d/bar.ts"`
72pub 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
89/// Compute a SARIF-compatible relative URI from an absolute path and project root.
90fn relative_uri(path: &Path, root: &Path) -> String {
91    normalize_uri(&relative_path(path, root).display().to_string())
92}
93
94/// Normalize a path string to a valid URI: forward slashes and percent-encoded brackets.
95///
96/// Brackets (`[`, `]`) are not valid in URI path segments per RFC 3986 and cause
97/// SARIF validation warnings (e.g., Next.js dynamic routes like `[slug]`).
98pub fn normalize_uri(path_str: &str) -> String {
99    path_str
100        .replace('\\', "/")
101        .replace('[', "%5B")
102        .replace(']', "%5D")
103}
104
105/// Severity level for human-readable output.
106#[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        // Off issues are filtered before reporting; fall back to Info.
118        Severity::Off => Level::Info,
119    }
120}
121
122/// Print analysis results in the configured format.
123/// Returns exit code 2 if serialization fails, SUCCESS otherwise.
124pub 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
148// ── Duplication report ────────────────────────────────────────────
149
150/// Print duplication analysis results in the configured format.
151pub 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
175// ── Health / complexity report ─────────────────────────────────────
176
177/// Print health (complexity) analysis results in the configured format.
178pub 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
202/// Print cross-reference findings (duplicated code that is also dead code).
203///
204/// Only emits output in human format to avoid corrupting structured JSON/SARIF output.
205pub 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
214// ── Trace output ──────────────────────────────────────────────────
215
216/// Print export trace results.
217pub 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
224/// Print file trace results.
225pub 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
232/// Print dependency trace results.
233pub 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
240/// Print clone trace results.
241pub 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
248/// Print pipeline performance timings.
249/// In JSON mode, outputs to stderr to avoid polluting the JSON analysis output on stdout.
250pub 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// Re-exported for snapshot testing via the lib target.
261// Uses #[allow] instead of #[expect] because unused_imports is target-dependent
262// (used in lib target, unused in bin target — #[expect] would be unfulfilled in one).
263#[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    // ── normalize_uri ────────────────────────────────────────────────
290
291    #[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    // ── relative_path ────────────────────────────────────────────────
320
321    #[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    // ── relative_uri ─────────────────────────────────────────────────
353
354    #[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    // ── severity_to_level ────────────────────────────────────────────
388
389    #[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    // ── normalize_uri bracket encoding ──────────────────────────────
405
406    #[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    // ── relative_path edge cases ────────────────────────────────────
447
448    #[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        // "/project-two/src/a.ts" should NOT strip "/project" because
457        // "/project" is not a proper prefix of "/project-two".
458        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    // ── relative_uri edge cases ─────────────────────────────────────
464
465    #[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        // Should both strip the prefix AND encode brackets.
471        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    // ── severity_to_level exhaustiveness ────────────────────────────
483
484    #[test]
485    fn severity_to_level_is_const_evaluable() {
486        // Verify the function can be used in const context.
487        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    // ── Level is Copy ───────────────────────────────────────────────
496
497    #[test]
498    fn level_is_copy() {
499        let level = severity_to_level(Severity::Error);
500        let copy = level;
501        // Both should still be usable (Copy semantics).
502        assert!(matches!(level, Level::Error));
503        assert!(matches!(copy, Level::Error));
504    }
505
506    // ── elide_common_prefix ─────────────────────────────────────────
507
508    #[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        // Same dir, different file
535        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}