Skip to main content

fallow_cli/report/
mod.rs

1mod compact;
2mod human;
3mod json;
4mod sarif;
5
6use std::path::Path;
7use std::process::ExitCode;
8use std::time::Duration;
9
10use fallow_config::{OutputFormat, ResolvedConfig, Severity};
11use fallow_core::duplicates::DuplicationReport;
12use fallow_core::results::AnalysisResults;
13use fallow_core::trace::{CloneTrace, DependencyTrace, ExportTrace, FileTrace, PipelineTimings};
14
15/// Strip the project root prefix from a path for display, falling back to the full path.
16fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
17    path.strip_prefix(root).unwrap_or(path)
18}
19
20/// Compute a SARIF-compatible relative URI from an absolute path and project root.
21fn relative_uri(path: &Path, root: &Path) -> String {
22    normalize_uri(&relative_path(path, root).display().to_string())
23}
24
25/// Normalize a path string to use forward slashes for cross-platform compatibility.
26pub fn normalize_uri(path_str: &str) -> String {
27    path_str.replace('\\', "/")
28}
29
30/// Severity level for human-readable output.
31#[derive(Clone, Copy)]
32enum Level {
33    Warn,
34    Info,
35    Error,
36}
37
38const fn severity_to_level(s: Severity) -> Level {
39    match s {
40        Severity::Error => Level::Error,
41        Severity::Warn => Level::Warn,
42        // Off issues are filtered before reporting; fall back to Info.
43        Severity::Off => Level::Info,
44    }
45}
46
47/// Print analysis results in the configured format.
48/// Returns exit code 2 if serialization fails, SUCCESS otherwise.
49pub fn print_results(
50    results: &AnalysisResults,
51    config: &ResolvedConfig,
52    elapsed: Duration,
53    quiet: bool,
54) -> ExitCode {
55    match config.output {
56        OutputFormat::Human => {
57            human::print_human(results, &config.root, &config.rules, elapsed, quiet);
58            ExitCode::SUCCESS
59        }
60        OutputFormat::Json => json::print_json(results, elapsed),
61        OutputFormat::Compact => {
62            compact::print_compact(results, &config.root);
63            ExitCode::SUCCESS
64        }
65        OutputFormat::Sarif => sarif::print_sarif(results, &config.root, &config.rules),
66    }
67}
68
69// ── Duplication report ────────────────────────────────────────────
70
71/// Print duplication analysis results in the configured format.
72pub fn print_duplication_report(
73    report: &DuplicationReport,
74    config: &ResolvedConfig,
75    elapsed: Duration,
76    quiet: bool,
77    output: &OutputFormat,
78) -> ExitCode {
79    match output {
80        OutputFormat::Human => {
81            human::print_duplication_human(report, &config.root, elapsed, quiet);
82            ExitCode::SUCCESS
83        }
84        OutputFormat::Json => json::print_duplication_json(report, elapsed),
85        OutputFormat::Compact => {
86            compact::print_duplication_compact(report, &config.root);
87            ExitCode::SUCCESS
88        }
89        OutputFormat::Sarif => sarif::print_duplication_sarif(report, &config.root),
90    }
91}
92
93/// Print cross-reference findings (duplicated code that is also dead code).
94///
95/// Only emits output in human format to avoid corrupting structured JSON/SARIF output.
96pub fn print_cross_reference_findings(
97    cross_ref: &fallow_core::cross_reference::CrossReferenceResult,
98    root: &Path,
99    quiet: bool,
100    output: &OutputFormat,
101) {
102    human::print_cross_reference_findings(cross_ref, root, quiet, output);
103}
104
105// ── Trace output ──────────────────────────────────────────────────
106
107/// Print export trace results.
108pub fn print_export_trace(trace: &ExportTrace, format: &OutputFormat) {
109    match format {
110        OutputFormat::Json => json::print_trace_json(trace),
111        _ => human::print_export_trace_human(trace),
112    }
113}
114
115/// Print file trace results.
116pub fn print_file_trace(trace: &FileTrace, format: &OutputFormat) {
117    match format {
118        OutputFormat::Json => json::print_trace_json(trace),
119        _ => human::print_file_trace_human(trace),
120    }
121}
122
123/// Print dependency trace results.
124pub fn print_dependency_trace(trace: &DependencyTrace, format: &OutputFormat) {
125    match format {
126        OutputFormat::Json => json::print_trace_json(trace),
127        _ => human::print_dependency_trace_human(trace),
128    }
129}
130
131/// Print clone trace results.
132pub fn print_clone_trace(trace: &CloneTrace, root: &Path, format: &OutputFormat) {
133    match format {
134        OutputFormat::Json => json::print_trace_json(trace),
135        _ => human::print_clone_trace_human(trace, root),
136    }
137}
138
139/// Print pipeline performance timings.
140/// In JSON mode, outputs to stderr to avoid polluting the JSON analysis output on stdout.
141pub fn print_performance(timings: &PipelineTimings, format: &OutputFormat) {
142    match format {
143        OutputFormat::Json => match serde_json::to_string_pretty(timings) {
144            Ok(json) => eprintln!("{json}"),
145            Err(e) => eprintln!("Error: failed to serialize timings: {e}"),
146        },
147        _ => human::print_performance_human(timings),
148    }
149}
150
151// Re-exported for snapshot testing via the lib target
152#[allow(unused_imports)]
153pub use compact::build_compact_lines;
154#[allow(unused_imports)]
155pub use json::build_json;
156#[allow(unused_imports)]
157pub use sarif::build_sarif;
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use std::path::PathBuf;
163
164    // ── normalize_uri ────────────────────────────────────────────────
165
166    #[test]
167    fn normalize_uri_forward_slashes_unchanged() {
168        assert_eq!(normalize_uri("src/utils.ts"), "src/utils.ts");
169    }
170
171    #[test]
172    fn normalize_uri_backslashes_replaced() {
173        assert_eq!(normalize_uri("src\\utils\\index.ts"), "src/utils/index.ts");
174    }
175
176    #[test]
177    fn normalize_uri_mixed_slashes() {
178        assert_eq!(normalize_uri("src\\utils/index.ts"), "src/utils/index.ts");
179    }
180
181    #[test]
182    fn normalize_uri_path_with_spaces() {
183        assert_eq!(
184            normalize_uri("src\\my folder\\file.ts"),
185            "src/my folder/file.ts"
186        );
187    }
188
189    #[test]
190    fn normalize_uri_empty_string() {
191        assert_eq!(normalize_uri(""), "");
192    }
193
194    // ── relative_path ────────────────────────────────────────────────
195
196    #[test]
197    fn relative_path_strips_root_prefix() {
198        let root = Path::new("/project");
199        let path = Path::new("/project/src/utils.ts");
200        assert_eq!(relative_path(path, root), Path::new("src/utils.ts"));
201    }
202
203    #[test]
204    fn relative_path_returns_full_path_when_no_prefix() {
205        let root = Path::new("/other");
206        let path = Path::new("/project/src/utils.ts");
207        assert_eq!(relative_path(path, root), path);
208    }
209
210    #[test]
211    fn relative_path_at_root_returns_empty_or_file() {
212        let root = Path::new("/project");
213        let path = Path::new("/project/file.ts");
214        assert_eq!(relative_path(path, root), Path::new("file.ts"));
215    }
216
217    #[test]
218    fn relative_path_deeply_nested() {
219        let root = Path::new("/project");
220        let path = Path::new("/project/packages/ui/src/components/Button.tsx");
221        assert_eq!(
222            relative_path(path, root),
223            Path::new("packages/ui/src/components/Button.tsx")
224        );
225    }
226
227    // ── relative_uri ─────────────────────────────────────────────────
228
229    #[test]
230    fn relative_uri_produces_forward_slash_path() {
231        let root = PathBuf::from("/project");
232        let path = root.join("src").join("utils.ts");
233        let uri = relative_uri(&path, &root);
234        assert_eq!(uri, "src/utils.ts");
235    }
236
237    #[test]
238    fn relative_uri_no_common_prefix_returns_full() {
239        let root = PathBuf::from("/other");
240        let path = PathBuf::from("/project/src/utils.ts");
241        let uri = relative_uri(&path, &root);
242        assert!(uri.contains("project"));
243        assert!(uri.contains("utils.ts"));
244    }
245
246    // ── severity_to_level ────────────────────────────────────────────
247
248    #[test]
249    fn severity_error_maps_to_level_error() {
250        assert!(matches!(severity_to_level(Severity::Error), Level::Error));
251    }
252
253    #[test]
254    fn severity_warn_maps_to_level_warn() {
255        assert!(matches!(severity_to_level(Severity::Warn), Level::Warn));
256    }
257
258    #[test]
259    fn severity_off_maps_to_level_info() {
260        assert!(matches!(severity_to_level(Severity::Off), Level::Info));
261    }
262}