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)]
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        // JSON, SARIF, and Markdown all use JSON output
128        OutputFormat::Json | OutputFormat::Sarif | OutputFormat::Markdown => {
129            json::print_health_json(report, &config.root, elapsed)
130        }
131    }
132}
133
134/// Print cross-reference findings (duplicated code that is also dead code).
135///
136/// Only emits output in human format to avoid corrupting structured JSON/SARIF output.
137pub fn print_cross_reference_findings(
138    cross_ref: &fallow_core::cross_reference::CrossReferenceResult,
139    root: &Path,
140    quiet: bool,
141    output: &OutputFormat,
142) {
143    human::print_cross_reference_findings(cross_ref, root, quiet, output);
144}
145
146// ── Trace output ──────────────────────────────────────────────────
147
148/// Print export trace results.
149pub fn print_export_trace(trace: &ExportTrace, format: &OutputFormat) {
150    match format {
151        OutputFormat::Json => json::print_trace_json(trace),
152        _ => human::print_export_trace_human(trace),
153    }
154}
155
156/// Print file trace results.
157pub fn print_file_trace(trace: &FileTrace, format: &OutputFormat) {
158    match format {
159        OutputFormat::Json => json::print_trace_json(trace),
160        _ => human::print_file_trace_human(trace),
161    }
162}
163
164/// Print dependency trace results.
165pub fn print_dependency_trace(trace: &DependencyTrace, format: &OutputFormat) {
166    match format {
167        OutputFormat::Json => json::print_trace_json(trace),
168        _ => human::print_dependency_trace_human(trace),
169    }
170}
171
172/// Print clone trace results.
173pub fn print_clone_trace(trace: &CloneTrace, root: &Path, format: &OutputFormat) {
174    match format {
175        OutputFormat::Json => json::print_trace_json(trace),
176        _ => human::print_clone_trace_human(trace, root),
177    }
178}
179
180/// Print pipeline performance timings.
181/// In JSON mode, outputs to stderr to avoid polluting the JSON analysis output on stdout.
182pub fn print_performance(timings: &PipelineTimings, format: &OutputFormat) {
183    match format {
184        OutputFormat::Json => match serde_json::to_string_pretty(timings) {
185            Ok(json) => eprintln!("{json}"),
186            Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
187        },
188        _ => human::print_performance_human(timings),
189    }
190}
191
192// Re-exported for snapshot testing via the lib target
193#[allow(unused_imports)]
194pub use compact::build_compact_lines;
195#[allow(unused_imports)]
196pub use json::build_json;
197#[allow(unused_imports)]
198pub use markdown::build_duplication_markdown;
199#[allow(unused_imports)]
200pub use markdown::build_markdown;
201#[allow(unused_imports)]
202pub use sarif::build_sarif;
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use std::path::PathBuf;
208
209    // ── normalize_uri ────────────────────────────────────────────────
210
211    #[test]
212    fn normalize_uri_forward_slashes_unchanged() {
213        assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
214    }
215
216    #[test]
217    fn normalize_uri_backslashes_replaced() {
218        assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
219    }
220
221    #[test]
222    fn normalize_uri_mixed_slashes() {
223        assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
224    }
225
226    #[test]
227    fn normalize_uri_path_with_spaces() {
228        assert_eq!(
229            normalize_uri("src\\my folder\\file.ts"),
230            "src/my folder/file.ts"
231        );
232    }
233
234    #[test]
235    fn normalize_uri_empty_string() {
236        assert_eq!(normalize_uri(""), "");
237    }
238
239    // ── relative_path ────────────────────────────────────────────────
240
241    #[test]
242    fn relative_path_strips_root_prefix() {
243        let root = Path::new("/project");
244        let path = Path::new("/project/src/utils.ts");
245        assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
246    }
247
248    #[test]
249    fn relative_path_returns_full_path_when_no_prefix() {
250        let root = Path::new("/other");
251        let path = Path::new("/project/src/utils.ts");
252        assert_eq!(relative_path(path, root), path);
253    }
254
255    #[test]
256    fn relative_path_at_root_returns_empty_or_file() {
257        let root = Path::new("/project");
258        let path = Path::new("/project/file.ts");
259        assert_eq!(relative_path(path, root), Path::new("file.ts"));
260    }
261
262    #[test]
263    fn relative_path_deeply_nested() {
264        let root = Path::new("/project");
265        let path = Path::new("/project/packages/ui/src/components/Button.tsx");
266        assert_eq!(
267            relative_path(path, root),
268            Path::new("packages/ui/src/components/Button.tsx")
269        );
270    }
271
272    // ── relative_uri ─────────────────────────────────────────────────
273
274    #[test]
275    fn relative_uri_produces_forward_slash_path() {
276        let root = PathBuf::from("/project");
277        let path = root.join("src").join("utils.ts");
278        let uri = relative_uri(&path, &root);
279        assert_eq!(uri, "src/utils.ts");
280    }
281
282    #[test]
283    fn relative_uri_encodes_brackets() {
284        let root = PathBuf::from("/project");
285        let path = root.join("src/app/[...slug]/page.tsx");
286        let uri = relative_uri(&path, &root);
287        assert_eq!(uri, "src/app/%5B...slug%5D/page.tsx");
288    }
289
290    #[test]
291    fn relative_uri_encodes_nested_dynamic_routes() {
292        let root = PathBuf::from("/project");
293        let path = root.join("src/app/[slug]/[id]/page.tsx");
294        let uri = relative_uri(&path, &root);
295        assert_eq!(uri, "src/app/%5Bslug%5D/%5Bid%5D/page.tsx");
296    }
297
298    #[test]
299    fn relative_uri_no_common_prefix_returns_full() {
300        let root = PathBuf::from("/other");
301        let path = PathBuf::from("/project/src/utils.ts");
302        let uri = relative_uri(&path, &root);
303        assert!(uri.contains("project"));
304        assert!(uri.contains("utils.ts"));
305    }
306
307    // ── severity_to_level ────────────────────────────────────────────
308
309    #[test]
310    fn severity_error_maps_to_level_error() {
311        assert!(matches!(severity_to_level(Severity::Error), Level::Error));
312    }
313
314    #[test]
315    fn severity_warn_maps_to_level_warn() {
316        assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
317    }
318
319    #[test]
320    fn severity_off_maps_to_level_info() {
321        assert!(matches!(severity_to_level(Severity::Off), Level::Info));
322    }
323}