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