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/// Compute a SARIF-compatible relative URI from an absolute path and project root.
22fn relative_uri(path: &Path, root: &Path) -> String {
23    normalize_uri(&relative_path(path, root).display().to_string())
24}
25
26/// Normalize a path string to a valid URI: forward slashes and percent-encoded brackets.
27///
28/// Brackets (`[`, `]`) are not valid in URI path segments per RFC 3986 and cause
29/// SARIF validation warnings (e.g., Next.js dynamic routes like `[slug]`).
30pub fn normalize_uri(path_str: &str) -> String {
31    path_str
32        .replace('\\', "/")
33        .replace('[', "%5B")
34        .replace(']', "%5D")
35}
36
37/// Severity level for human-readable output.
38#[derive(Clone, Copy, Debug)]
39enum Level {
40    Warn,
41    Info,
42    Error,
43}
44
45const fn severity_to_level(s: Severity) -> Level {
46    match s {
47        Severity::Error => Level::Error,
48        Severity::Warn => Level::Warn,
49        // Off issues are filtered before reporting; fall back to Info.
50        Severity::Off => Level::Info,
51    }
52}
53
54/// Print analysis results in the configured format.
55/// Returns exit code 2 if serialization fails, SUCCESS otherwise.
56pub fn print_results(
57    results: &AnalysisResults,
58    config: &ResolvedConfig,
59    elapsed: Duration,
60    quiet: bool,
61) -> ExitCode {
62    match config.output {
63        OutputFormat::Human => {
64            human::print_human(results, &config.root, &config.rules, elapsed, quiet);
65            ExitCode::SUCCESS
66        }
67        OutputFormat::Json => json::print_json(results, &config.root, elapsed),
68        OutputFormat::Compact => {
69            compact::print_compact(results, &config.root);
70            ExitCode::SUCCESS
71        }
72        OutputFormat::Sarif => sarif::print_sarif(results, &config.root, &config.rules),
73        OutputFormat::Markdown => {
74            markdown::print_markdown(results, &config.root);
75            ExitCode::SUCCESS
76        }
77    }
78}
79
80// ── Duplication report ────────────────────────────────────────────
81
82/// Print duplication analysis results in the configured format.
83pub fn print_duplication_report(
84    report: &DuplicationReport,
85    config: &ResolvedConfig,
86    elapsed: Duration,
87    quiet: bool,
88    output: &OutputFormat,
89) -> ExitCode {
90    match output {
91        OutputFormat::Human => {
92            human::print_duplication_human(report, &config.root, elapsed, quiet);
93            ExitCode::SUCCESS
94        }
95        OutputFormat::Json => json::print_duplication_json(report, elapsed),
96        OutputFormat::Compact => {
97            compact::print_duplication_compact(report, &config.root);
98            ExitCode::SUCCESS
99        }
100        OutputFormat::Sarif => sarif::print_duplication_sarif(report, &config.root),
101        OutputFormat::Markdown => {
102            markdown::print_duplication_markdown(report, &config.root);
103            ExitCode::SUCCESS
104        }
105    }
106}
107
108// ── Health / complexity report ─────────────────────────────────────
109
110/// Print health (complexity) analysis results in the configured format.
111pub fn print_health_report(
112    report: &crate::health_types::HealthReport,
113    config: &ResolvedConfig,
114    elapsed: Duration,
115    quiet: bool,
116    output: &OutputFormat,
117) -> ExitCode {
118    match output {
119        OutputFormat::Human => {
120            human::print_health_human(report, &config.root, elapsed, quiet);
121            ExitCode::SUCCESS
122        }
123        OutputFormat::Compact => {
124            compact::print_health_compact(report, &config.root);
125            ExitCode::SUCCESS
126        }
127        OutputFormat::Markdown => {
128            markdown::print_health_markdown(report, &config.root);
129            ExitCode::SUCCESS
130        }
131        OutputFormat::Sarif => sarif::print_health_sarif(report, &config.root),
132        OutputFormat::Json => json::print_health_json(report, &config.root, elapsed),
133    }
134}
135
136/// Print cross-reference findings (duplicated code that is also dead code).
137///
138/// Only emits output in human format to avoid corrupting structured JSON/SARIF output.
139pub fn print_cross_reference_findings(
140    cross_ref: &fallow_core::cross_reference::CrossReferenceResult,
141    root: &Path,
142    quiet: bool,
143    output: &OutputFormat,
144) {
145    human::print_cross_reference_findings(cross_ref, root, quiet, output);
146}
147
148// ── Trace output ──────────────────────────────────────────────────
149
150/// Print export trace results.
151pub fn print_export_trace(trace: &ExportTrace, format: &OutputFormat) {
152    match format {
153        OutputFormat::Json => json::print_trace_json(trace),
154        _ => human::print_export_trace_human(trace),
155    }
156}
157
158/// Print file trace results.
159pub fn print_file_trace(trace: &FileTrace, format: &OutputFormat) {
160    match format {
161        OutputFormat::Json => json::print_trace_json(trace),
162        _ => human::print_file_trace_human(trace),
163    }
164}
165
166/// Print dependency trace results.
167pub fn print_dependency_trace(trace: &DependencyTrace, format: &OutputFormat) {
168    match format {
169        OutputFormat::Json => json::print_trace_json(trace),
170        _ => human::print_dependency_trace_human(trace),
171    }
172}
173
174/// Print clone trace results.
175pub fn print_clone_trace(trace: &CloneTrace, root: &Path, format: &OutputFormat) {
176    match format {
177        OutputFormat::Json => json::print_trace_json(trace),
178        _ => human::print_clone_trace_human(trace, root),
179    }
180}
181
182/// Print pipeline performance timings.
183/// In JSON mode, outputs to stderr to avoid polluting the JSON analysis output on stdout.
184pub fn print_performance(timings: &PipelineTimings, format: &OutputFormat) {
185    match format {
186        OutputFormat::Json => match serde_json::to_string_pretty(timings) {
187            Ok(json) => eprintln!("{json}"),
188            Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
189        },
190        _ => human::print_performance_human(timings),
191    }
192}
193
194// Re-exported for snapshot testing via the lib target
195#[allow(unused_imports)]
196pub use compact::build_compact_lines;
197#[allow(unused_imports)]
198pub use json::build_json;
199#[allow(unused_imports)]
200pub use markdown::build_duplication_markdown;
201#[allow(unused_imports)]
202pub use markdown::build_health_markdown;
203#[allow(unused_imports)]
204pub use markdown::build_markdown;
205#[allow(unused_imports)]
206pub use sarif::build_health_sarif;
207#[allow(unused_imports)]
208pub use sarif::build_sarif;
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use std::path::PathBuf;
214
215    // ── normalize_uri ────────────────────────────────────────────────
216
217    #[test]
218    fn normalize_uri_forward_slashes_unchanged() {
219        assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
220    }
221
222    #[test]
223    fn normalize_uri_backslashes_replaced() {
224        assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
225    }
226
227    #[test]
228    fn normalize_uri_mixed_slashes() {
229        assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
230    }
231
232    #[test]
233    fn normalize_uri_path_with_spaces() {
234        assert_eq!(
235            normalize_uri("src\\my folder\\file.ts"),
236            "src/my folder/file.ts"
237        );
238    }
239
240    #[test]
241    fn normalize_uri_empty_string() {
242        assert_eq!(normalize_uri(""), "");
243    }
244
245    // ── relative_path ────────────────────────────────────────────────
246
247    #[test]
248    fn relative_path_strips_root_prefix() {
249        let root = Path::new("/project");
250        let path = Path::new("/project/src/utils.ts");
251        assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
252    }
253
254    #[test]
255    fn relative_path_returns_full_path_when_no_prefix() {
256        let root = Path::new("/other");
257        let path = Path::new("/project/src/utils.ts");
258        assert_eq!(relative_path(path, root), path);
259    }
260
261    #[test]
262    fn relative_path_at_root_returns_empty_or_file() {
263        let root = Path::new("/project");
264        let path = Path::new("/project/file.ts");
265        assert_eq!(relative_path(path, root), Path::new("file.ts"));
266    }
267
268    #[test]
269    fn relative_path_deeply_nested() {
270        let root = Path::new("/project");
271        let path = Path::new("/project/packages/ui/src/components/Button.tsx");
272        assert_eq!(
273            relative_path(path, root),
274            Path::new("packages/ui/src/components/Button.tsx")
275        );
276    }
277
278    // ── relative_uri ─────────────────────────────────────────────────
279
280    #[test]
281    fn relative_uri_produces_forward_slash_path() {
282        let root = PathBuf::from("/project");
283        let path = root.join("src").join("utils.ts");
284        let uri = relative_uri(&path, &root);
285        assert_eq!(uri, "src/utils.ts");
286    }
287
288    #[test]
289    fn relative_uri_encodes_brackets() {
290        let root = PathBuf::from("/project");
291        let path = root.join("src/app/[...slug]/page.tsx");
292        let uri = relative_uri(&path, &root);
293        assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
294    }
295
296    #[test]
297    fn relative_uri_encodes_nested_dynamic_routes() {
298        let root = PathBuf::from("/project");
299        let path = root.join("src/app/[slug]/[id]/page.tsx");
300        let uri = relative_uri(&path, &root);
301        assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
302    }
303
304    #[test]
305    fn relative_uri_no_common_prefix_returns_full() {
306        let root = PathBuf::from("/other");
307        let path = PathBuf::from("/project/src/utils.ts");
308        let uri = relative_uri(&path, &root);
309        assert!(uri.contains("project"));
310        assert!(uri.contains("utils.ts"));
311    }
312
313    // ── severity_to_level ────────────────────────────────────────────
314
315    #[test]
316    fn severity_error_maps_to_level_error() {
317        assert!(matches!(severity_to_level(Severity::Error), Level::Error));
318    }
319
320    #[test]
321    fn severity_warn_maps_to_level_warn() {
322        assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
323    }
324
325    #[test]
326    fn severity_off_maps_to_level_info() {
327        assert!(matches!(severity_to_level(Severity::Off), Level::Info));
328    }
329
330    // ── normalize_uri bracket encoding ──────────────────────────────
331
332    #[test]
333    fn normalize_uri_single_bracket_pair() {
334        assert_eq!(normalize_uri("app/[id]/page.tsx"), "app/%5Bid%5D/page.tsx");
335    }
336
337    #[test]
338    fn normalize_uri_catch_all_route() {
339        assert_eq!(
340            normalize_uri("app/[...slug]/page.tsx"),
341            "app/%5B...slug%5D/page.tsx"
342        );
343    }
344
345    #[test]
346    fn normalize_uri_optional_catch_all_route() {
347        assert_eq!(
348            normalize_uri("app/[[...slug]]/page.tsx"),
349            "app/%5B%5B...slug%5D%5D/page.tsx"
350        );
351    }
352
353    #[test]
354    fn normalize_uri_multiple_dynamic_segments() {
355        assert_eq!(
356            normalize_uri("app/[lang]/posts/[id]"),
357            "app/%5Blang%5D/posts/%5Bid%5D"
358        );
359    }
360
361    #[test]
362    fn normalize_uri_no_special_chars() {
363        let plain = "src/components/Button.tsx";
364        assert_eq!(normalize_uri(plain), plain);
365    }
366
367    #[test]
368    fn normalize_uri_only_backslashes() {
369        assert_eq!(normalize_uri("a\\b\\c"), "a/b/c");
370    }
371
372    // ── relative_path edge cases ────────────────────────────────────
373
374    #[test]
375    fn relative_path_identical_paths_returns_empty() {
376        let root = Path::new("/project");
377        assert_eq!(relative_path(root, root), Path::new(""));
378    }
379
380    #[test]
381    fn relative_path_partial_name_match_not_stripped() {
382        // "/project-two/src/a.ts" should NOT strip "/project" because
383        // "/project" is not a proper prefix of "/project-two".
384        let root = Path::new("/project");
385        let path = Path::new("/project-two/src/a.ts");
386        assert_eq!(relative_path(path, root), path);
387    }
388
389    // ── relative_uri edge cases ─────────────────────────────────────
390
391    #[test]
392    fn relative_uri_combines_stripping_and_encoding() {
393        let root = PathBuf::from("/project");
394        let path = root.join("src/app/[slug]/page.tsx");
395        let uri = relative_uri(&path, &root);
396        // Should both strip the prefix AND encode brackets.
397        assert_eq!(uri, "src/app/%5Bslug%5D/page.tsx");
398        assert!(!uri.starts_with('/'));
399    }
400
401    #[test]
402    fn relative_uri_at_root_file() {
403        let root = PathBuf::from("/project");
404        let path = root.join("index.ts");
405        assert_eq!(relative_uri(&path, &root), "index.ts");
406    }
407
408    // ── severity_to_level exhaustiveness ────────────────────────────
409
410    #[test]
411    fn severity_to_level_is_const_evaluable() {
412        // Verify the function can be used in const context.
413        const LEVEL_FROM_ERROR: Level = severity_to_level(Severity::Error);
414        const LEVEL_FROM_WARN: Level = severity_to_level(Severity::Warn);
415        const LEVEL_FROM_OFF: Level = severity_to_level(Severity::Off);
416        assert!(matches!(LEVEL_FROM_ERROR, Level::Error));
417        assert!(matches!(LEVEL_FROM_WARN, Level::Warn));
418        assert!(matches!(LEVEL_FROM_OFF, Level::Info));
419    }
420
421    // ── Level is Copy ───────────────────────────────────────────────
422
423    #[test]
424    fn level_is_copy() {
425        let level = severity_to_level(Severity::Error);
426        let copy = level;
427        // Both should still be usable (Copy semantics).
428        assert!(matches!(level, Level::Error));
429        assert!(matches!(copy, Level::Error));
430    }
431}