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.
17fn 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)`.
23fn 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/// Compute a SARIF-compatible relative URI from an absolute path and project root.
31fn relative_uri(path: &Path, root: &Path) -> String {
32    normalize_uri(&relative_path(path, root).display().to_string())
33}
34
35/// Normalize a path string to a valid URI: forward slashes and percent-encoded brackets.
36///
37/// Brackets (`[`, `]`) are not valid in URI path segments per RFC 3986 and cause
38/// SARIF validation warnings (e.g., Next.js dynamic routes like `[slug]`).
39pub fn normalize_uri(path_str: &str) -> String {
40    path_str
41        .replace('\\', "/")
42        .replace('[', "%5B")
43        .replace(']', "%5D")
44}
45
46/// Severity level for human-readable output.
47#[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        // Off issues are filtered before reporting; fall back to Info.
59        Severity::Off => Level::Info,
60    }
61}
62
63/// Print analysis results in the configured format.
64/// Returns exit code 2 if serialization fails, SUCCESS otherwise.
65pub 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
89// ── Duplication report ────────────────────────────────────────────
90
91/// Print duplication analysis results in the configured format.
92pub 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
117// ── Health / complexity report ─────────────────────────────────────
118
119/// Print health (complexity) analysis results in the configured format.
120pub 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
145/// Print cross-reference findings (duplicated code that is also dead code).
146///
147/// Only emits output in human format to avoid corrupting structured JSON/SARIF output.
148pub 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
157// ── Trace output ──────────────────────────────────────────────────
158
159/// Print export trace results.
160pub 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
167/// Print file trace results.
168pub 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
175/// Print dependency trace results.
176pub 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
183/// Print clone trace results.
184pub 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
191/// Print pipeline performance timings.
192/// In JSON mode, outputs to stderr to avoid polluting the JSON analysis output on stdout.
193pub 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// Re-exported for snapshot testing via the lib target
204#[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    // ── normalize_uri ────────────────────────────────────────────────
225
226    #[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    // ── relative_path ────────────────────────────────────────────────
255
256    #[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    // ── relative_uri ─────────────────────────────────────────────────
288
289    #[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    // ── severity_to_level ────────────────────────────────────────────
323
324    #[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    // ── normalize_uri bracket encoding ──────────────────────────────
340
341    #[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    // ── relative_path edge cases ────────────────────────────────────
382
383    #[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        // "/project-two/src/a.ts" should NOT strip "/project" because
392        // "/project" is not a proper prefix of "/project-two".
393        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    // ── relative_uri edge cases ─────────────────────────────────────
399
400    #[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        // Should both strip the prefix AND encode brackets.
406        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    // ── severity_to_level exhaustiveness ────────────────────────────
418
419    #[test]
420    fn severity_to_level_is_const_evaluable() {
421        // Verify the function can be used in const context.
422        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    // ── Level is Copy ───────────────────────────────────────────────
431
432    #[test]
433    fn level_is_copy() {
434        let level = severity_to_level(Severity::Error);
435        let copy = level;
436        // Both should still be usable (Copy semantics).
437        assert!(matches!(level, Level::Error));
438        assert!(matches!(copy, Level::Error));
439    }
440}