Skip to main content

fallow_cli/report/
mod.rs

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
16/// Strip the project root prefix from a path for display, falling back to the full path.
17pub fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
18    path.strip_prefix(root).unwrap_or(path)
19}
20
21/// Split a path string into (directory, filename) for display.
22/// Directory includes the trailing `/`. If no directory, returns `("", filename)`.
23pub 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
30/// Elide the common directory prefix between a base path and a target path.
31/// Only strips complete directory segments (never partial filenames).
32/// Returns the remaining suffix of `target`.
33///
34/// Example: `elide_common_prefix("a/b/c/foo.ts", "a/b/d/bar.ts")` → `"d/bar.ts"`
35pub 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
52/// Compute a SARIF-compatible relative URI from an absolute path and project root.
53fn relative_uri(path: &Path, root: &Path) -> String {
54    normalize_uri(&relative_path(path, root).display().to_string())
55}
56
57/// Normalize a path string to a valid URI: forward slashes and percent-encoded brackets.
58///
59/// Brackets (`[`, `]`) are not valid in URI path segments per RFC 3986 and cause
60/// SARIF validation warnings (e.g., Next.js dynamic routes like `[slug]`).
61pub fn normalize_uri(path_str: &str) -> String {
62    path_str
63        .replace('\\', "/")
64        .replace('[', "%5B")
65        .replace(']', "%5D")
66}
67
68/// Severity level for human-readable output.
69#[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        // Off issues are filtered before reporting; fall back to Info.
81        Severity::Off => Level::Info,
82    }
83}
84
85/// Print analysis results in the configured format.
86/// Returns exit code 2 if serialization fails, SUCCESS otherwise.
87pub 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
112// ── Duplication report ────────────────────────────────────────────
113
114/// Print duplication analysis results in the configured format.
115pub 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
141// ── Health / complexity report ─────────────────────────────────────
142
143/// Print health (complexity) analysis results in the configured format.
144pub 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
170/// Print cross-reference findings (duplicated code that is also dead code).
171///
172/// Only emits output in human format to avoid corrupting structured JSON/SARIF output.
173pub 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
182// ── Trace output ──────────────────────────────────────────────────
183
184/// Print export trace results.
185pub 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
192/// Print file trace results.
193pub 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
200/// Print dependency trace results.
201pub 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
208/// Print clone trace results.
209pub 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
216/// Print pipeline performance timings.
217/// In JSON mode, outputs to stderr to avoid polluting the JSON analysis output on stdout.
218pub 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// Re-exported for snapshot testing via the lib target.
229// Uses #[allow] instead of #[expect] because unused_imports is target-dependent
230// (used in lib target, unused in bin target — #[expect] would be unfulfilled in one).
231#[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    // ── normalize_uri ────────────────────────────────────────────────
252
253    #[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    // ── relative_path ────────────────────────────────────────────────
282
283    #[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    // ── relative_uri ─────────────────────────────────────────────────
315
316    #[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    // ── severity_to_level ────────────────────────────────────────────
350
351    #[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    // ── normalize_uri bracket encoding ──────────────────────────────
367
368    #[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    // ── relative_path edge cases ────────────────────────────────────
409
410    #[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        // "/project-two/src/a.ts" should NOT strip "/project" because
419        // "/project" is not a proper prefix of "/project-two".
420        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    // ── relative_uri edge cases ─────────────────────────────────────
426
427    #[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        // Should both strip the prefix AND encode brackets.
433        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    // ── severity_to_level exhaustiveness ────────────────────────────
445
446    #[test]
447    fn severity_to_level_is_const_evaluable() {
448        // Verify the function can be used in const context.
449        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    // ── Level is Copy ───────────────────────────────────────────────
458
459    #[test]
460    fn level_is_copy() {
461        let level = severity_to_level(Severity::Error);
462        let copy = level;
463        // Both should still be usable (Copy semantics).
464        assert!(matches!(level, Level::Error));
465        assert!(matches!(copy, Level::Error));
466    }
467
468    // ── elide_common_prefix ─────────────────────────────────────────
469
470    #[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        // Same dir, different file
497        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}