Skip to main content

fallow_cli/report/
json.rs

1use std::process::ExitCode;
2use std::time::Duration;
3
4use fallow_core::duplicates::DuplicationReport;
5use fallow_core::results::AnalysisResults;
6
7pub(super) fn print_json(results: &AnalysisResults, elapsed: Duration) -> ExitCode {
8    match build_json(results, elapsed) {
9        Ok(output) => match serde_json::to_string_pretty(&output) {
10            Ok(json) => {
11                println!("{json}");
12                ExitCode::SUCCESS
13            }
14            Err(e) => {
15                eprintln!("Error: failed to serialize JSON output: {e}");
16                ExitCode::from(2)
17            }
18        },
19        Err(e) => {
20            eprintln!("Error: failed to serialize results: {e}");
21            ExitCode::from(2)
22        }
23    }
24}
25
26/// JSON output schema version as an integer (independent of tool version).
27///
28/// Bump this when the structure of the JSON output changes in a
29/// backwards-incompatible way (removing/renaming fields, changing types).
30/// Adding new fields is always backwards-compatible and does not require a bump.
31const SCHEMA_VERSION: u32 = 1;
32
33/// Build the JSON output value for analysis results.
34///
35/// Metadata fields (`schema_version`, `version`, `elapsed_ms`, `total_issues`)
36/// appear first in the output for readability.
37pub fn build_json(
38    results: &AnalysisResults,
39    elapsed: Duration,
40) -> Result<serde_json::Value, serde_json::Error> {
41    let results_value = serde_json::to_value(results)?;
42
43    let mut map = serde_json::Map::new();
44    map.insert(
45        "schema_version".to_string(),
46        serde_json::json!(SCHEMA_VERSION),
47    );
48    map.insert(
49        "version".to_string(),
50        serde_json::json!(env!("CARGO_PKG_VERSION")),
51    );
52    map.insert(
53        "elapsed_ms".to_string(),
54        serde_json::json!(elapsed.as_millis()),
55    );
56    map.insert(
57        "total_issues".to_string(),
58        serde_json::json!(results.total_issues()),
59    );
60
61    if let serde_json::Value::Object(results_map) = results_value {
62        for (key, value) in results_map {
63            map.insert(key, value);
64        }
65    }
66
67    Ok(serde_json::Value::Object(map))
68}
69
70pub(super) fn print_duplication_json(report: &DuplicationReport, elapsed: Duration) -> ExitCode {
71    let report_value = match serde_json::to_value(report) {
72        Ok(v) => v,
73        Err(e) => {
74            eprintln!("Error: failed to serialize duplication report: {e}");
75            return ExitCode::from(2);
76        }
77    };
78
79    // Metadata fields first, then report data
80    let mut map = serde_json::Map::new();
81    map.insert(
82        "schema_version".to_string(),
83        serde_json::json!(SCHEMA_VERSION),
84    );
85    map.insert(
86        "version".to_string(),
87        serde_json::json!(env!("CARGO_PKG_VERSION")),
88    );
89    map.insert(
90        "elapsed_ms".to_string(),
91        serde_json::json!(elapsed.as_millis()),
92    );
93    if let serde_json::Value::Object(report_map) = report_value {
94        for (key, value) in report_map {
95            map.insert(key, value);
96        }
97    }
98    let output = serde_json::Value::Object(map);
99
100    match serde_json::to_string_pretty(&output) {
101        Ok(json) => {
102            println!("{json}");
103            ExitCode::SUCCESS
104        }
105        Err(e) => {
106            eprintln!("Error: failed to serialize JSON output: {e}");
107            ExitCode::from(2)
108        }
109    }
110}
111
112pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
113    match serde_json::to_string_pretty(value) {
114        Ok(json) => println!("{json}"),
115        Err(e) => {
116            eprintln!("Error: failed to serialize trace output: {e}");
117            #[expect(clippy::exit)]
118            std::process::exit(2);
119        }
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use fallow_core::extract::MemberKind;
127    use fallow_core::results::*;
128    use std::path::{Path, PathBuf};
129    use std::time::Duration;
130
131    /// Helper: build an `AnalysisResults` populated with one issue of every type.
132    fn sample_results(root: &Path) -> AnalysisResults {
133        let mut r = AnalysisResults::default();
134
135        r.unused_files.push(UnusedFile {
136            path: root.join("src/dead.ts"),
137        });
138        r.unused_exports.push(UnusedExport {
139            path: root.join("src/utils.ts"),
140            export_name: "helperFn".to_string(),
141            is_type_only: false,
142            line: 10,
143            col: 4,
144            span_start: 120,
145            is_re_export: false,
146        });
147        r.unused_types.push(UnusedExport {
148            path: root.join("src/types.ts"),
149            export_name: "OldType".to_string(),
150            is_type_only: true,
151            line: 5,
152            col: 0,
153            span_start: 60,
154            is_re_export: false,
155        });
156        r.unused_dependencies.push(UnusedDependency {
157            package_name: "lodash".to_string(),
158            location: DependencyLocation::Dependencies,
159            path: root.join("package.json"),
160        });
161        r.unused_dev_dependencies.push(UnusedDependency {
162            package_name: "jest".to_string(),
163            location: DependencyLocation::DevDependencies,
164            path: root.join("package.json"),
165        });
166        r.unused_enum_members.push(UnusedMember {
167            path: root.join("src/enums.ts"),
168            parent_name: "Status".to_string(),
169            member_name: "Deprecated".to_string(),
170            kind: MemberKind::EnumMember,
171            line: 8,
172            col: 2,
173        });
174        r.unused_class_members.push(UnusedMember {
175            path: root.join("src/service.ts"),
176            parent_name: "UserService".to_string(),
177            member_name: "legacyMethod".to_string(),
178            kind: MemberKind::ClassMethod,
179            line: 42,
180            col: 4,
181        });
182        r.unresolved_imports.push(UnresolvedImport {
183            path: root.join("src/app.ts"),
184            specifier: "./missing-module".to_string(),
185            line: 3,
186            col: 0,
187        });
188        r.unlisted_dependencies.push(UnlistedDependency {
189            package_name: "chalk".to_string(),
190            imported_from: vec![root.join("src/cli.ts")],
191        });
192        r.duplicate_exports.push(DuplicateExport {
193            export_name: "Config".to_string(),
194            locations: vec![root.join("src/config.ts"), root.join("src/types.ts")],
195        });
196        r.circular_dependencies.push(CircularDependency {
197            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
198            length: 2,
199        });
200
201        r
202    }
203
204    #[test]
205    fn json_output_has_metadata_fields() {
206        let results = AnalysisResults::default();
207        let elapsed = Duration::from_millis(123);
208        let output = build_json(&results, elapsed).expect("should serialize");
209
210        assert_eq!(output["schema_version"], 1);
211        assert!(output["version"].is_string());
212        assert_eq!(output["elapsed_ms"], 123);
213        assert_eq!(output["total_issues"], 0);
214    }
215
216    #[test]
217    fn json_output_includes_issue_arrays() {
218        let root = PathBuf::from("/project");
219        let results = sample_results(&root);
220        let elapsed = Duration::from_millis(50);
221        let output = build_json(&results, elapsed).expect("should serialize");
222
223        assert!(output["unused_files"].is_array());
224        assert!(output["unused_exports"].is_array());
225        assert!(output["unused_types"].is_array());
226        assert!(output["unused_dependencies"].is_array());
227        assert!(output["unused_dev_dependencies"].is_array());
228        assert!(output["unused_enum_members"].is_array());
229        assert!(output["unused_class_members"].is_array());
230        assert!(output["unresolved_imports"].is_array());
231        assert!(output["unlisted_dependencies"].is_array());
232        assert!(output["duplicate_exports"].is_array());
233        assert!(output["type_only_dependencies"].is_array());
234    }
235
236    #[test]
237    fn json_metadata_fields_appear_first() {
238        let results = AnalysisResults::default();
239        let elapsed = Duration::from_millis(0);
240        let output = build_json(&results, elapsed).expect("should serialize");
241        let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
242        assert_eq!(keys[0], "schema_version");
243        assert_eq!(keys[1], "version");
244        assert_eq!(keys[2], "elapsed_ms");
245        assert_eq!(keys[3], "total_issues");
246    }
247
248    #[test]
249    fn json_total_issues_matches_results() {
250        let root = PathBuf::from("/project");
251        let results = sample_results(&root);
252        let total = results.total_issues();
253        let elapsed = Duration::from_millis(0);
254        let output = build_json(&results, elapsed).expect("should serialize");
255
256        assert_eq!(output["total_issues"], total);
257    }
258
259    #[test]
260    fn json_unused_export_contains_expected_fields() {
261        let mut results = AnalysisResults::default();
262        results.unused_exports.push(UnusedExport {
263            path: PathBuf::from("/project/src/utils.ts"),
264            export_name: "helperFn".to_string(),
265            is_type_only: false,
266            line: 10,
267            col: 4,
268            span_start: 120,
269            is_re_export: false,
270        });
271        let elapsed = Duration::from_millis(0);
272        let output = build_json(&results, elapsed).expect("should serialize");
273
274        let export = &output["unused_exports"][0];
275        assert_eq!(export["export_name"], "helperFn");
276        assert_eq!(export["line"], 10);
277        assert_eq!(export["col"], 4);
278        assert_eq!(export["is_type_only"], false);
279        assert_eq!(export["span_start"], 120);
280        assert_eq!(export["is_re_export"], false);
281    }
282
283    #[test]
284    fn json_serializes_to_valid_json() {
285        let root = PathBuf::from("/project");
286        let results = sample_results(&root);
287        let elapsed = Duration::from_millis(42);
288        let output = build_json(&results, elapsed).expect("should serialize");
289
290        let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
291        let reparsed: serde_json::Value =
292            serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
293        assert_eq!(reparsed, output);
294    }
295}