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
26const SCHEMA_VERSION: u32 = 1;
32
33pub 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 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 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}