Skip to main content

fallow_cli/report/
json.rs

1use std::path::Path;
2use std::process::ExitCode;
3use std::time::Duration;
4
5use fallow_core::duplicates::DuplicationReport;
6use fallow_core::results::AnalysisResults;
7
8pub(super) fn print_json(results: &AnalysisResults, root: &Path, elapsed: Duration) -> ExitCode {
9    match build_json(results, root, elapsed) {
10        Ok(output) => match serde_json::to_string_pretty(&output) {
11            Ok(json) => {
12                println!("{json}");
13                ExitCode::SUCCESS
14            }
15            Err(e) => {
16                eprintln!("Error: failed to serialize JSON output: {e}");
17                ExitCode::from(2)
18            }
19        },
20        Err(e) => {
21            eprintln!("Error: failed to serialize results: {e}");
22            ExitCode::from(2)
23        }
24    }
25}
26
27/// JSON output schema version as an integer (independent of tool version).
28///
29/// Bump this when the structure of the JSON output changes in a
30/// backwards-incompatible way (removing/renaming fields, changing types).
31/// Adding new fields is always backwards-compatible and does not require a bump.
32const SCHEMA_VERSION: u32 = 3;
33
34/// Build the JSON output value for analysis results.
35///
36/// Metadata fields (`schema_version`, `version`, `elapsed_ms`, `total_issues`)
37/// appear first in the output for readability. Paths are made relative to `root`.
38pub fn build_json(
39    results: &AnalysisResults,
40    root: &Path,
41    elapsed: Duration,
42) -> Result<serde_json::Value, serde_json::Error> {
43    let results_value = serde_json::to_value(results)?;
44
45    let mut map = serde_json::Map::new();
46    map.insert(
47        "schema_version".to_string(),
48        serde_json::json!(SCHEMA_VERSION),
49    );
50    map.insert(
51        "version".to_string(),
52        serde_json::json!(env!("CARGO_PKG_VERSION")),
53    );
54    map.insert(
55        "elapsed_ms".to_string(),
56        serde_json::json!(elapsed.as_millis()),
57    );
58    map.insert(
59        "total_issues".to_string(),
60        serde_json::json!(results.total_issues()),
61    );
62
63    if let serde_json::Value::Object(results_map) = results_value {
64        for (key, value) in results_map {
65            map.insert(key, value);
66        }
67    }
68
69    let mut output = serde_json::Value::Object(map);
70    let root_prefix = format!("{}/", root.display());
71    strip_root_prefix(&mut output, &root_prefix);
72    Ok(output)
73}
74
75/// Recursively strip the root prefix from all string values in the JSON tree.
76///
77/// This converts absolute paths (e.g., `/home/runner/work/repo/repo/src/utils.ts`)
78/// to relative paths (`src/utils.ts`) for all output fields.
79fn strip_root_prefix(value: &mut serde_json::Value, prefix: &str) {
80    match value {
81        serde_json::Value::String(s) => {
82            if let Some(rest) = s.strip_prefix(prefix) {
83                *s = rest.to_string();
84            }
85        }
86        serde_json::Value::Array(arr) => {
87            for item in arr {
88                strip_root_prefix(item, prefix);
89            }
90        }
91        serde_json::Value::Object(map) => {
92            for (_, v) in map.iter_mut() {
93                strip_root_prefix(v, prefix);
94            }
95        }
96        _ => {}
97    }
98}
99
100pub(super) fn print_health_json(
101    report: &crate::health_types::HealthReport,
102    root: &Path,
103    elapsed: Duration,
104) -> ExitCode {
105    let report_value = match serde_json::to_value(report) {
106        Ok(v) => v,
107        Err(e) => {
108            eprintln!("Error: failed to serialize health report: {e}");
109            return ExitCode::from(2);
110        }
111    };
112
113    let mut map = serde_json::Map::new();
114    map.insert(
115        "schema_version".to_string(),
116        serde_json::json!(SCHEMA_VERSION),
117    );
118    map.insert(
119        "version".to_string(),
120        serde_json::json!(env!("CARGO_PKG_VERSION")),
121    );
122    map.insert(
123        "elapsed_ms".to_string(),
124        serde_json::json!(elapsed.as_millis()),
125    );
126    if let serde_json::Value::Object(report_map) = report_value {
127        for (key, value) in report_map {
128            map.insert(key, value);
129        }
130    }
131    let mut output = serde_json::Value::Object(map);
132    let root_prefix = format!("{}/", root.display());
133    strip_root_prefix(&mut output, &root_prefix);
134
135    match serde_json::to_string_pretty(&output) {
136        Ok(json) => {
137            println!("{json}");
138            ExitCode::SUCCESS
139        }
140        Err(e) => {
141            eprintln!("Error: failed to serialize JSON output: {e}");
142            ExitCode::from(2)
143        }
144    }
145}
146
147pub(super) fn print_duplication_json(report: &DuplicationReport, elapsed: Duration) -> ExitCode {
148    let report_value = match serde_json::to_value(report) {
149        Ok(v) => v,
150        Err(e) => {
151            eprintln!("Error: failed to serialize duplication report: {e}");
152            return ExitCode::from(2);
153        }
154    };
155
156    // Metadata fields first, then report data
157    let mut map = serde_json::Map::new();
158    map.insert(
159        "schema_version".to_string(),
160        serde_json::json!(SCHEMA_VERSION),
161    );
162    map.insert(
163        "version".to_string(),
164        serde_json::json!(env!("CARGO_PKG_VERSION")),
165    );
166    map.insert(
167        "elapsed_ms".to_string(),
168        serde_json::json!(elapsed.as_millis()),
169    );
170    if let serde_json::Value::Object(report_map) = report_value {
171        for (key, value) in report_map {
172            map.insert(key, value);
173        }
174    }
175    let output = serde_json::Value::Object(map);
176
177    match serde_json::to_string_pretty(&output) {
178        Ok(json) => {
179            println!("{json}");
180            ExitCode::SUCCESS
181        }
182        Err(e) => {
183            eprintln!("Error: failed to serialize JSON output: {e}");
184            ExitCode::from(2)
185        }
186    }
187}
188
189pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
190    match serde_json::to_string_pretty(value) {
191        Ok(json) => println!("{json}"),
192        Err(e) => {
193            eprintln!("Error: failed to serialize trace output: {e}");
194            #[expect(clippy::exit)]
195            std::process::exit(2);
196        }
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use fallow_core::extract::MemberKind;
204    use fallow_core::results::*;
205    use std::path::{Path, PathBuf};
206    use std::time::Duration;
207
208    /// Helper: build an `AnalysisResults` populated with one issue of every type.
209    fn sample_results(root: &Path) -> AnalysisResults {
210        let mut r = AnalysisResults::default();
211
212        r.unused_files.push(UnusedFile {
213            path: root.join("src/dead.ts"),
214        });
215        r.unused_exports.push(UnusedExport {
216            path: root.join("src/utils.ts"),
217            export_name: "helperFn".to_string(),
218            is_type_only: false,
219            line: 10,
220            col: 4,
221            span_start: 120,
222            is_re_export: false,
223        });
224        r.unused_types.push(UnusedExport {
225            path: root.join("src/types.ts"),
226            export_name: "OldType".to_string(),
227            is_type_only: true,
228            line: 5,
229            col: 0,
230            span_start: 60,
231            is_re_export: false,
232        });
233        r.unused_dependencies.push(UnusedDependency {
234            package_name: "lodash".to_string(),
235            location: DependencyLocation::Dependencies,
236            path: root.join("package.json"),
237            line: 5,
238        });
239        r.unused_dev_dependencies.push(UnusedDependency {
240            package_name: "jest".to_string(),
241            location: DependencyLocation::DevDependencies,
242            path: root.join("package.json"),
243            line: 5,
244        });
245        r.unused_enum_members.push(UnusedMember {
246            path: root.join("src/enums.ts"),
247            parent_name: "Status".to_string(),
248            member_name: "Deprecated".to_string(),
249            kind: MemberKind::EnumMember,
250            line: 8,
251            col: 2,
252        });
253        r.unused_class_members.push(UnusedMember {
254            path: root.join("src/service.ts"),
255            parent_name: "UserService".to_string(),
256            member_name: "legacyMethod".to_string(),
257            kind: MemberKind::ClassMethod,
258            line: 42,
259            col: 4,
260        });
261        r.unresolved_imports.push(UnresolvedImport {
262            path: root.join("src/app.ts"),
263            specifier: "./missing-module".to_string(),
264            line: 3,
265            col: 0,
266        });
267        r.unlisted_dependencies.push(UnlistedDependency {
268            package_name: "chalk".to_string(),
269            imported_from: vec![ImportSite {
270                path: root.join("src/cli.ts"),
271                line: 2,
272                col: 0,
273            }],
274        });
275        r.duplicate_exports.push(DuplicateExport {
276            export_name: "Config".to_string(),
277            locations: vec![
278                DuplicateLocation {
279                    path: root.join("src/config.ts"),
280                    line: 15,
281                    col: 0,
282                },
283                DuplicateLocation {
284                    path: root.join("src/types.ts"),
285                    line: 30,
286                    col: 0,
287                },
288            ],
289        });
290        r.circular_dependencies.push(CircularDependency {
291            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
292            length: 2,
293            line: 3,
294            col: 0,
295        });
296
297        r
298    }
299
300    #[test]
301    fn json_output_has_metadata_fields() {
302        let root = PathBuf::from("/project");
303        let results = AnalysisResults::default();
304        let elapsed = Duration::from_millis(123);
305        let output = build_json(&results, &root, elapsed).expect("should serialize");
306
307        assert_eq!(output["schema_version"], 3);
308        assert!(output["version"].is_string());
309        assert_eq!(output["elapsed_ms"], 123);
310        assert_eq!(output["total_issues"], 0);
311    }
312
313    #[test]
314    fn json_output_includes_issue_arrays() {
315        let root = PathBuf::from("/project");
316        let results = sample_results(&root);
317        let elapsed = Duration::from_millis(50);
318        let output = build_json(&results, &root, elapsed).expect("should serialize");
319
320        assert!(output["unused_files"].is_array());
321        assert!(output["unused_exports"].is_array());
322        assert!(output["unused_types"].is_array());
323        assert!(output["unused_dependencies"].is_array());
324        assert!(output["unused_dev_dependencies"].is_array());
325        assert!(output["unused_enum_members"].is_array());
326        assert!(output["unused_class_members"].is_array());
327        assert!(output["unresolved_imports"].is_array());
328        assert!(output["unlisted_dependencies"].is_array());
329        assert!(output["duplicate_exports"].is_array());
330        assert!(output["type_only_dependencies"].is_array());
331    }
332
333    #[test]
334    fn json_metadata_fields_appear_first() {
335        let root = PathBuf::from("/project");
336        let results = AnalysisResults::default();
337        let elapsed = Duration::from_millis(0);
338        let output = build_json(&results, &root, elapsed).expect("should serialize");
339        let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
340        assert_eq!(keys[0], "schema_version");
341        assert_eq!(keys[1], "version");
342        assert_eq!(keys[2], "elapsed_ms");
343        assert_eq!(keys[3], "total_issues");
344    }
345
346    #[test]
347    fn json_total_issues_matches_results() {
348        let root = PathBuf::from("/project");
349        let results = sample_results(&root);
350        let total = results.total_issues();
351        let elapsed = Duration::from_millis(0);
352        let output = build_json(&results, &root, elapsed).expect("should serialize");
353
354        assert_eq!(output["total_issues"], total);
355    }
356
357    #[test]
358    fn json_unused_export_contains_expected_fields() {
359        let root = PathBuf::from("/project");
360        let mut results = AnalysisResults::default();
361        results.unused_exports.push(UnusedExport {
362            path: root.join("src/utils.ts"),
363            export_name: "helperFn".to_string(),
364            is_type_only: false,
365            line: 10,
366            col: 4,
367            span_start: 120,
368            is_re_export: false,
369        });
370        let elapsed = Duration::from_millis(0);
371        let output = build_json(&results, &root, elapsed).expect("should serialize");
372
373        let export = &output["unused_exports"][0];
374        assert_eq!(export["export_name"], "helperFn");
375        assert_eq!(export["line"], 10);
376        assert_eq!(export["col"], 4);
377        assert_eq!(export["is_type_only"], false);
378        assert_eq!(export["span_start"], 120);
379        assert_eq!(export["is_re_export"], false);
380    }
381
382    #[test]
383    fn json_serializes_to_valid_json() {
384        let root = PathBuf::from("/project");
385        let results = sample_results(&root);
386        let elapsed = Duration::from_millis(42);
387        let output = build_json(&results, &root, elapsed).expect("should serialize");
388
389        let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
390        let reparsed: serde_json::Value =
391            serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
392        assert_eq!(reparsed, output);
393    }
394}