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
8use super::emit_json;
9use crate::explain;
10
11pub(super) fn print_json(
12    results: &AnalysisResults,
13    root: &Path,
14    elapsed: Duration,
15    explain: bool,
16) -> ExitCode {
17    match build_json(results, root, elapsed) {
18        Ok(mut output) => {
19            if explain {
20                insert_meta(&mut output, explain::check_meta());
21            }
22            emit_json(&output, "JSON")
23        }
24        Err(e) => {
25            eprintln!("Error: failed to serialize results: {e}");
26            ExitCode::from(2)
27        }
28    }
29}
30
31/// JSON output schema version as an integer (independent of tool version).
32///
33/// Bump this when the structure of the JSON output changes in a
34/// backwards-incompatible way (removing/renaming fields, changing types).
35/// Adding new fields is always backwards-compatible and does not require a bump.
36const SCHEMA_VERSION: u32 = 3;
37
38/// Build a JSON envelope with standard metadata fields at the top.
39///
40/// Creates a JSON object with `schema_version`, `version`, and `elapsed_ms`,
41/// then merges all fields from `report_value` into the envelope.
42/// Fields from `report_value` appear after the metadata header.
43fn build_json_envelope(report_value: serde_json::Value, elapsed: Duration) -> serde_json::Value {
44    let mut map = serde_json::Map::new();
45    map.insert(
46        "schema_version".to_string(),
47        serde_json::json!(SCHEMA_VERSION),
48    );
49    map.insert(
50        "version".to_string(),
51        serde_json::json!(env!("CARGO_PKG_VERSION")),
52    );
53    map.insert(
54        "elapsed_ms".to_string(),
55        serde_json::json!(elapsed.as_millis()),
56    );
57    if let serde_json::Value::Object(report_map) = report_value {
58        for (key, value) in report_map {
59            map.insert(key, value);
60        }
61    }
62    serde_json::Value::Object(map)
63}
64
65/// Build the JSON output value for analysis results.
66///
67/// Metadata fields (`schema_version`, `version`, `elapsed_ms`, `total_issues`)
68/// appear first in the output for readability. Paths are made relative to `root`.
69pub fn build_json(
70    results: &AnalysisResults,
71    root: &Path,
72    elapsed: Duration,
73) -> Result<serde_json::Value, serde_json::Error> {
74    let results_value = serde_json::to_value(results)?;
75
76    let mut map = serde_json::Map::new();
77    map.insert(
78        "schema_version".to_string(),
79        serde_json::json!(SCHEMA_VERSION),
80    );
81    map.insert(
82        "version".to_string(),
83        serde_json::json!(env!("CARGO_PKG_VERSION")),
84    );
85    map.insert(
86        "elapsed_ms".to_string(),
87        serde_json::json!(elapsed.as_millis()),
88    );
89    map.insert(
90        "total_issues".to_string(),
91        serde_json::json!(results.total_issues()),
92    );
93
94    if let serde_json::Value::Object(results_map) = results_value {
95        for (key, value) in results_map {
96            map.insert(key, value);
97        }
98    }
99
100    let mut output = serde_json::Value::Object(map);
101    let root_prefix = format!("{}/", root.display());
102    strip_root_prefix(&mut output, &root_prefix);
103    Ok(output)
104}
105
106/// Recursively strip the root prefix from all string values in the JSON tree.
107///
108/// This converts absolute paths (e.g., `/home/runner/work/repo/repo/src/utils.ts`)
109/// to relative paths (`src/utils.ts`) for all output fields.
110fn strip_root_prefix(value: &mut serde_json::Value, prefix: &str) {
111    match value {
112        serde_json::Value::String(s) => {
113            if let Some(rest) = s.strip_prefix(prefix) {
114                *s = rest.to_string();
115            }
116        }
117        serde_json::Value::Array(arr) => {
118            for item in arr {
119                strip_root_prefix(item, prefix);
120            }
121        }
122        serde_json::Value::Object(map) => {
123            for (_, v) in map.iter_mut() {
124                strip_root_prefix(v, prefix);
125            }
126        }
127        _ => {}
128    }
129}
130
131/// Insert a `_meta` key into a JSON object value.
132fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
133    if let serde_json::Value::Object(map) = output {
134        map.insert("_meta".to_string(), meta);
135    }
136}
137
138pub(super) fn print_health_json(
139    report: &crate::health_types::HealthReport,
140    root: &Path,
141    elapsed: Duration,
142    explain: bool,
143) -> ExitCode {
144    let report_value = match serde_json::to_value(report) {
145        Ok(v) => v,
146        Err(e) => {
147            eprintln!("Error: failed to serialize health report: {e}");
148            return ExitCode::from(2);
149        }
150    };
151
152    let mut output = build_json_envelope(report_value, elapsed);
153    let root_prefix = format!("{}/", root.display());
154    strip_root_prefix(&mut output, &root_prefix);
155
156    if explain {
157        insert_meta(&mut output, explain::health_meta());
158    }
159
160    emit_json(&output, "JSON")
161}
162
163pub(super) fn print_duplication_json(
164    report: &DuplicationReport,
165    elapsed: Duration,
166    explain: bool,
167) -> ExitCode {
168    let report_value = match serde_json::to_value(report) {
169        Ok(v) => v,
170        Err(e) => {
171            eprintln!("Error: failed to serialize duplication report: {e}");
172            return ExitCode::from(2);
173        }
174    };
175
176    let mut output = build_json_envelope(report_value, elapsed);
177
178    if explain {
179        insert_meta(&mut output, explain::dupes_meta());
180    }
181
182    emit_json(&output, "JSON")
183}
184
185pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
186    match serde_json::to_string_pretty(value) {
187        Ok(json) => println!("{json}"),
188        Err(e) => {
189            eprintln!("Error: failed to serialize trace output: {e}");
190            #[expect(clippy::exit)]
191            std::process::exit(2);
192        }
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::report::test_helpers::sample_results;
200    use fallow_core::extract::MemberKind;
201    use fallow_core::results::*;
202    use std::path::PathBuf;
203    use std::time::Duration;
204
205    #[test]
206    fn json_output_has_metadata_fields() {
207        let root = PathBuf::from("/project");
208        let results = AnalysisResults::default();
209        let elapsed = Duration::from_millis(123);
210        let output = build_json(&results, &root, elapsed).expect("should serialize");
211
212        assert_eq!(output["schema_version"], 3);
213        assert!(output["version"].is_string());
214        assert_eq!(output["elapsed_ms"], 123);
215        assert_eq!(output["total_issues"], 0);
216    }
217
218    #[test]
219    fn json_output_includes_issue_arrays() {
220        let root = PathBuf::from("/project");
221        let results = sample_results(&root);
222        let elapsed = Duration::from_millis(50);
223        let output = build_json(&results, &root, elapsed).expect("should serialize");
224
225        assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
226        assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
227        assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
228        assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
229        assert_eq!(
230            output["unused_dev_dependencies"].as_array().unwrap().len(),
231            1
232        );
233        assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
234        assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
235        assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
236        assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
237        assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
238        assert_eq!(
239            output["type_only_dependencies"].as_array().unwrap().len(),
240            1
241        );
242        assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
243    }
244
245    #[test]
246    fn json_metadata_fields_appear_first() {
247        let root = PathBuf::from("/project");
248        let results = AnalysisResults::default();
249        let elapsed = Duration::from_millis(0);
250        let output = build_json(&results, &root, elapsed).expect("should serialize");
251        let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
252        assert_eq!(keys[0], "schema_version");
253        assert_eq!(keys[1], "version");
254        assert_eq!(keys[2], "elapsed_ms");
255        assert_eq!(keys[3], "total_issues");
256    }
257
258    #[test]
259    fn json_total_issues_matches_results() {
260        let root = PathBuf::from("/project");
261        let results = sample_results(&root);
262        let total = results.total_issues();
263        let elapsed = Duration::from_millis(0);
264        let output = build_json(&results, &root, elapsed).expect("should serialize");
265
266        assert_eq!(output["total_issues"], total);
267    }
268
269    #[test]
270    fn json_unused_export_contains_expected_fields() {
271        let root = PathBuf::from("/project");
272        let mut results = AnalysisResults::default();
273        results.unused_exports.push(UnusedExport {
274            path: root.join("src/utils.ts"),
275            export_name: "helperFn".to_string(),
276            is_type_only: false,
277            line: 10,
278            col: 4,
279            span_start: 120,
280            is_re_export: false,
281        });
282        let elapsed = Duration::from_millis(0);
283        let output = build_json(&results, &root, elapsed).expect("should serialize");
284
285        let export = &output["unused_exports"][0];
286        assert_eq!(export["export_name"], "helperFn");
287        assert_eq!(export["line"], 10);
288        assert_eq!(export["col"], 4);
289        assert_eq!(export["is_type_only"], false);
290        assert_eq!(export["span_start"], 120);
291        assert_eq!(export["is_re_export"], false);
292    }
293
294    #[test]
295    fn json_serializes_to_valid_json() {
296        let root = PathBuf::from("/project");
297        let results = sample_results(&root);
298        let elapsed = Duration::from_millis(42);
299        let output = build_json(&results, &root, elapsed).expect("should serialize");
300
301        let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
302        let reparsed: serde_json::Value =
303            serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
304        assert_eq!(reparsed, output);
305    }
306
307    // ── Empty results ───────────────────────────────────────────────
308
309    #[test]
310    fn json_empty_results_produce_valid_structure() {
311        let root = PathBuf::from("/project");
312        let results = AnalysisResults::default();
313        let elapsed = Duration::from_millis(0);
314        let output = build_json(&results, &root, elapsed).expect("should serialize");
315
316        assert_eq!(output["total_issues"], 0);
317        assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
318        assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
319        assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
320        assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
321        assert_eq!(
322            output["unused_dev_dependencies"].as_array().unwrap().len(),
323            0
324        );
325        assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
326        assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
327        assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
328        assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
329        assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
330        assert_eq!(
331            output["type_only_dependencies"].as_array().unwrap().len(),
332            0
333        );
334        assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
335    }
336
337    #[test]
338    fn json_empty_results_round_trips_through_string() {
339        let root = PathBuf::from("/project");
340        let results = AnalysisResults::default();
341        let elapsed = Duration::from_millis(0);
342        let output = build_json(&results, &root, elapsed).expect("should serialize");
343
344        let json_str = serde_json::to_string(&output).expect("should stringify");
345        let reparsed: serde_json::Value =
346            serde_json::from_str(&json_str).expect("should parse back");
347        assert_eq!(reparsed["total_issues"], 0);
348    }
349
350    // ── Path stripping ──────────────────────────────────────────────
351
352    #[test]
353    fn json_paths_are_relative_to_root() {
354        let root = PathBuf::from("/project");
355        let mut results = AnalysisResults::default();
356        results.unused_files.push(UnusedFile {
357            path: root.join("src/deep/nested/file.ts"),
358        });
359        let elapsed = Duration::from_millis(0);
360        let output = build_json(&results, &root, elapsed).expect("should serialize");
361
362        let path = output["unused_files"][0]["path"].as_str().unwrap();
363        assert_eq!(path, "src/deep/nested/file.ts");
364        assert!(!path.starts_with("/project"));
365    }
366
367    #[test]
368    fn json_strips_root_from_nested_locations() {
369        let root = PathBuf::from("/project");
370        let mut results = AnalysisResults::default();
371        results.unlisted_dependencies.push(UnlistedDependency {
372            package_name: "chalk".to_string(),
373            imported_from: vec![ImportSite {
374                path: root.join("src/cli.ts"),
375                line: 2,
376                col: 0,
377            }],
378        });
379        let elapsed = Duration::from_millis(0);
380        let output = build_json(&results, &root, elapsed).expect("should serialize");
381
382        let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
383            .as_str()
384            .unwrap();
385        assert_eq!(site_path, "src/cli.ts");
386    }
387
388    #[test]
389    fn json_strips_root_from_duplicate_export_locations() {
390        let root = PathBuf::from("/project");
391        let mut results = AnalysisResults::default();
392        results.duplicate_exports.push(DuplicateExport {
393            export_name: "Config".to_string(),
394            locations: vec![
395                DuplicateLocation {
396                    path: root.join("src/config.ts"),
397                    line: 15,
398                    col: 0,
399                },
400                DuplicateLocation {
401                    path: root.join("src/types.ts"),
402                    line: 30,
403                    col: 0,
404                },
405            ],
406        });
407        let elapsed = Duration::from_millis(0);
408        let output = build_json(&results, &root, elapsed).expect("should serialize");
409
410        let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
411            .as_str()
412            .unwrap();
413        let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
414            .as_str()
415            .unwrap();
416        assert_eq!(loc0, "src/config.ts");
417        assert_eq!(loc1, "src/types.ts");
418    }
419
420    #[test]
421    fn json_strips_root_from_circular_dependency_files() {
422        let root = PathBuf::from("/project");
423        let mut results = AnalysisResults::default();
424        results.circular_dependencies.push(CircularDependency {
425            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
426            length: 2,
427            line: 1,
428            col: 0,
429        });
430        let elapsed = Duration::from_millis(0);
431        let output = build_json(&results, &root, elapsed).expect("should serialize");
432
433        let files = output["circular_dependencies"][0]["files"]
434            .as_array()
435            .unwrap();
436        assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
437        assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
438    }
439
440    #[test]
441    fn json_path_outside_root_not_stripped() {
442        let root = PathBuf::from("/project");
443        let mut results = AnalysisResults::default();
444        results.unused_files.push(UnusedFile {
445            path: PathBuf::from("/other/project/src/file.ts"),
446        });
447        let elapsed = Duration::from_millis(0);
448        let output = build_json(&results, &root, elapsed).expect("should serialize");
449
450        let path = output["unused_files"][0]["path"].as_str().unwrap();
451        assert!(path.contains("/other/project/"));
452    }
453
454    // ── Individual issue type field verification ────────────────────
455
456    #[test]
457    fn json_unused_file_contains_path() {
458        let root = PathBuf::from("/project");
459        let mut results = AnalysisResults::default();
460        results.unused_files.push(UnusedFile {
461            path: root.join("src/orphan.ts"),
462        });
463        let elapsed = Duration::from_millis(0);
464        let output = build_json(&results, &root, elapsed).expect("should serialize");
465
466        let file = &output["unused_files"][0];
467        assert_eq!(file["path"], "src/orphan.ts");
468    }
469
470    #[test]
471    fn json_unused_type_contains_expected_fields() {
472        let root = PathBuf::from("/project");
473        let mut results = AnalysisResults::default();
474        results.unused_types.push(UnusedExport {
475            path: root.join("src/types.ts"),
476            export_name: "OldInterface".to_string(),
477            is_type_only: true,
478            line: 20,
479            col: 0,
480            span_start: 300,
481            is_re_export: false,
482        });
483        let elapsed = Duration::from_millis(0);
484        let output = build_json(&results, &root, elapsed).expect("should serialize");
485
486        let typ = &output["unused_types"][0];
487        assert_eq!(typ["export_name"], "OldInterface");
488        assert_eq!(typ["is_type_only"], true);
489        assert_eq!(typ["line"], 20);
490        assert_eq!(typ["path"], "src/types.ts");
491    }
492
493    #[test]
494    fn json_unused_dependency_contains_expected_fields() {
495        let root = PathBuf::from("/project");
496        let mut results = AnalysisResults::default();
497        results.unused_dependencies.push(UnusedDependency {
498            package_name: "axios".to_string(),
499            location: DependencyLocation::Dependencies,
500            path: root.join("package.json"),
501            line: 10,
502        });
503        let elapsed = Duration::from_millis(0);
504        let output = build_json(&results, &root, elapsed).expect("should serialize");
505
506        let dep = &output["unused_dependencies"][0];
507        assert_eq!(dep["package_name"], "axios");
508        assert_eq!(dep["line"], 10);
509    }
510
511    #[test]
512    fn json_unused_dev_dependency_contains_expected_fields() {
513        let root = PathBuf::from("/project");
514        let mut results = AnalysisResults::default();
515        results.unused_dev_dependencies.push(UnusedDependency {
516            package_name: "vitest".to_string(),
517            location: DependencyLocation::DevDependencies,
518            path: root.join("package.json"),
519            line: 15,
520        });
521        let elapsed = Duration::from_millis(0);
522        let output = build_json(&results, &root, elapsed).expect("should serialize");
523
524        let dep = &output["unused_dev_dependencies"][0];
525        assert_eq!(dep["package_name"], "vitest");
526    }
527
528    #[test]
529    fn json_unused_optional_dependency_contains_expected_fields() {
530        let root = PathBuf::from("/project");
531        let mut results = AnalysisResults::default();
532        results.unused_optional_dependencies.push(UnusedDependency {
533            package_name: "fsevents".to_string(),
534            location: DependencyLocation::OptionalDependencies,
535            path: root.join("package.json"),
536            line: 12,
537        });
538        let elapsed = Duration::from_millis(0);
539        let output = build_json(&results, &root, elapsed).expect("should serialize");
540
541        let dep = &output["unused_optional_dependencies"][0];
542        assert_eq!(dep["package_name"], "fsevents");
543        assert_eq!(output["total_issues"], 1);
544    }
545
546    #[test]
547    fn json_unused_enum_member_contains_expected_fields() {
548        let root = PathBuf::from("/project");
549        let mut results = AnalysisResults::default();
550        results.unused_enum_members.push(UnusedMember {
551            path: root.join("src/enums.ts"),
552            parent_name: "Color".to_string(),
553            member_name: "Purple".to_string(),
554            kind: MemberKind::EnumMember,
555            line: 5,
556            col: 2,
557        });
558        let elapsed = Duration::from_millis(0);
559        let output = build_json(&results, &root, elapsed).expect("should serialize");
560
561        let member = &output["unused_enum_members"][0];
562        assert_eq!(member["parent_name"], "Color");
563        assert_eq!(member["member_name"], "Purple");
564        assert_eq!(member["line"], 5);
565        assert_eq!(member["path"], "src/enums.ts");
566    }
567
568    #[test]
569    fn json_unused_class_member_contains_expected_fields() {
570        let root = PathBuf::from("/project");
571        let mut results = AnalysisResults::default();
572        results.unused_class_members.push(UnusedMember {
573            path: root.join("src/api.ts"),
574            parent_name: "ApiClient".to_string(),
575            member_name: "deprecatedFetch".to_string(),
576            kind: MemberKind::ClassMethod,
577            line: 100,
578            col: 4,
579        });
580        let elapsed = Duration::from_millis(0);
581        let output = build_json(&results, &root, elapsed).expect("should serialize");
582
583        let member = &output["unused_class_members"][0];
584        assert_eq!(member["parent_name"], "ApiClient");
585        assert_eq!(member["member_name"], "deprecatedFetch");
586        assert_eq!(member["line"], 100);
587    }
588
589    #[test]
590    fn json_unresolved_import_contains_expected_fields() {
591        let root = PathBuf::from("/project");
592        let mut results = AnalysisResults::default();
593        results.unresolved_imports.push(UnresolvedImport {
594            path: root.join("src/app.ts"),
595            specifier: "@acme/missing-pkg".to_string(),
596            line: 7,
597            col: 0,
598            specifier_col: 0,
599        });
600        let elapsed = Duration::from_millis(0);
601        let output = build_json(&results, &root, elapsed).expect("should serialize");
602
603        let import = &output["unresolved_imports"][0];
604        assert_eq!(import["specifier"], "@acme/missing-pkg");
605        assert_eq!(import["line"], 7);
606        assert_eq!(import["path"], "src/app.ts");
607    }
608
609    #[test]
610    fn json_unlisted_dependency_contains_import_sites() {
611        let root = PathBuf::from("/project");
612        let mut results = AnalysisResults::default();
613        results.unlisted_dependencies.push(UnlistedDependency {
614            package_name: "dotenv".to_string(),
615            imported_from: vec![
616                ImportSite {
617                    path: root.join("src/config.ts"),
618                    line: 1,
619                    col: 0,
620                },
621                ImportSite {
622                    path: root.join("src/server.ts"),
623                    line: 3,
624                    col: 0,
625                },
626            ],
627        });
628        let elapsed = Duration::from_millis(0);
629        let output = build_json(&results, &root, elapsed).expect("should serialize");
630
631        let dep = &output["unlisted_dependencies"][0];
632        assert_eq!(dep["package_name"], "dotenv");
633        let sites = dep["imported_from"].as_array().unwrap();
634        assert_eq!(sites.len(), 2);
635        assert_eq!(sites[0]["path"], "src/config.ts");
636        assert_eq!(sites[1]["path"], "src/server.ts");
637    }
638
639    #[test]
640    fn json_duplicate_export_contains_locations() {
641        let root = PathBuf::from("/project");
642        let mut results = AnalysisResults::default();
643        results.duplicate_exports.push(DuplicateExport {
644            export_name: "Button".to_string(),
645            locations: vec![
646                DuplicateLocation {
647                    path: root.join("src/ui.ts"),
648                    line: 10,
649                    col: 0,
650                },
651                DuplicateLocation {
652                    path: root.join("src/components.ts"),
653                    line: 25,
654                    col: 0,
655                },
656            ],
657        });
658        let elapsed = Duration::from_millis(0);
659        let output = build_json(&results, &root, elapsed).expect("should serialize");
660
661        let dup = &output["duplicate_exports"][0];
662        assert_eq!(dup["export_name"], "Button");
663        let locs = dup["locations"].as_array().unwrap();
664        assert_eq!(locs.len(), 2);
665        assert_eq!(locs[0]["line"], 10);
666        assert_eq!(locs[1]["line"], 25);
667    }
668
669    #[test]
670    fn json_type_only_dependency_contains_expected_fields() {
671        let root = PathBuf::from("/project");
672        let mut results = AnalysisResults::default();
673        results.type_only_dependencies.push(TypeOnlyDependency {
674            package_name: "zod".to_string(),
675            path: root.join("package.json"),
676            line: 8,
677        });
678        let elapsed = Duration::from_millis(0);
679        let output = build_json(&results, &root, elapsed).expect("should serialize");
680
681        let dep = &output["type_only_dependencies"][0];
682        assert_eq!(dep["package_name"], "zod");
683        assert_eq!(dep["line"], 8);
684    }
685
686    #[test]
687    fn json_circular_dependency_contains_expected_fields() {
688        let root = PathBuf::from("/project");
689        let mut results = AnalysisResults::default();
690        results.circular_dependencies.push(CircularDependency {
691            files: vec![
692                root.join("src/a.ts"),
693                root.join("src/b.ts"),
694                root.join("src/c.ts"),
695            ],
696            length: 3,
697            line: 5,
698            col: 0,
699        });
700        let elapsed = Duration::from_millis(0);
701        let output = build_json(&results, &root, elapsed).expect("should serialize");
702
703        let cycle = &output["circular_dependencies"][0];
704        assert_eq!(cycle["length"], 3);
705        assert_eq!(cycle["line"], 5);
706        let files = cycle["files"].as_array().unwrap();
707        assert_eq!(files.len(), 3);
708    }
709
710    // ── Re-export tagging ───────────────────────────────────────────
711
712    #[test]
713    fn json_re_export_flagged_correctly() {
714        let root = PathBuf::from("/project");
715        let mut results = AnalysisResults::default();
716        results.unused_exports.push(UnusedExport {
717            path: root.join("src/index.ts"),
718            export_name: "reExported".to_string(),
719            is_type_only: false,
720            line: 1,
721            col: 0,
722            span_start: 0,
723            is_re_export: true,
724        });
725        let elapsed = Duration::from_millis(0);
726        let output = build_json(&results, &root, elapsed).expect("should serialize");
727
728        assert_eq!(output["unused_exports"][0]["is_re_export"], true);
729    }
730
731    // ── Schema version stability ────────────────────────────────────
732
733    #[test]
734    fn json_schema_version_is_3() {
735        let root = PathBuf::from("/project");
736        let results = AnalysisResults::default();
737        let elapsed = Duration::from_millis(0);
738        let output = build_json(&results, &root, elapsed).expect("should serialize");
739
740        assert_eq!(output["schema_version"], SCHEMA_VERSION);
741        assert_eq!(output["schema_version"], 3);
742    }
743
744    // ── Version string ──────────────────────────────────────────────
745
746    #[test]
747    fn json_version_matches_cargo_pkg_version() {
748        let root = PathBuf::from("/project");
749        let results = AnalysisResults::default();
750        let elapsed = Duration::from_millis(0);
751        let output = build_json(&results, &root, elapsed).expect("should serialize");
752
753        assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
754    }
755
756    // ── Elapsed time encoding ───────────────────────────────────────
757
758    #[test]
759    fn json_elapsed_ms_zero_duration() {
760        let root = PathBuf::from("/project");
761        let results = AnalysisResults::default();
762        let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
763
764        assert_eq!(output["elapsed_ms"], 0);
765    }
766
767    #[test]
768    fn json_elapsed_ms_large_duration() {
769        let root = PathBuf::from("/project");
770        let results = AnalysisResults::default();
771        let elapsed = Duration::from_secs(120);
772        let output = build_json(&results, &root, elapsed).expect("should serialize");
773
774        assert_eq!(output["elapsed_ms"], 120_000);
775    }
776
777    #[test]
778    fn json_elapsed_ms_sub_millisecond_truncated() {
779        let root = PathBuf::from("/project");
780        let results = AnalysisResults::default();
781        // 500 microseconds = 0 milliseconds (truncated)
782        let elapsed = Duration::from_micros(500);
783        let output = build_json(&results, &root, elapsed).expect("should serialize");
784
785        assert_eq!(output["elapsed_ms"], 0);
786    }
787
788    // ── Multiple issues of same type ────────────────────────────────
789
790    #[test]
791    fn json_multiple_unused_files() {
792        let root = PathBuf::from("/project");
793        let mut results = AnalysisResults::default();
794        results.unused_files.push(UnusedFile {
795            path: root.join("src/a.ts"),
796        });
797        results.unused_files.push(UnusedFile {
798            path: root.join("src/b.ts"),
799        });
800        results.unused_files.push(UnusedFile {
801            path: root.join("src/c.ts"),
802        });
803        let elapsed = Duration::from_millis(0);
804        let output = build_json(&results, &root, elapsed).expect("should serialize");
805
806        assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
807        assert_eq!(output["total_issues"], 3);
808    }
809
810    // ── strip_root_prefix unit tests ────────────────────────────────
811
812    #[test]
813    fn strip_root_prefix_on_string_value() {
814        let mut value = serde_json::json!("/project/src/file.ts");
815        strip_root_prefix(&mut value, "/project/");
816        assert_eq!(value, "src/file.ts");
817    }
818
819    #[test]
820    fn strip_root_prefix_leaves_non_matching_string() {
821        let mut value = serde_json::json!("/other/src/file.ts");
822        strip_root_prefix(&mut value, "/project/");
823        assert_eq!(value, "/other/src/file.ts");
824    }
825
826    #[test]
827    fn strip_root_prefix_recurses_into_arrays() {
828        let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
829        strip_root_prefix(&mut value, "/project/");
830        assert_eq!(value[0], "a.ts");
831        assert_eq!(value[1], "b.ts");
832        assert_eq!(value[2], "/other/c.ts");
833    }
834
835    #[test]
836    fn strip_root_prefix_recurses_into_nested_objects() {
837        let mut value = serde_json::json!({
838            "outer": {
839                "path": "/project/src/nested.ts"
840            }
841        });
842        strip_root_prefix(&mut value, "/project/");
843        assert_eq!(value["outer"]["path"], "src/nested.ts");
844    }
845
846    #[test]
847    fn strip_root_prefix_leaves_numbers_and_booleans() {
848        let mut value = serde_json::json!({
849            "line": 42,
850            "is_type_only": false,
851            "path": "/project/src/file.ts"
852        });
853        strip_root_prefix(&mut value, "/project/");
854        assert_eq!(value["line"], 42);
855        assert_eq!(value["is_type_only"], false);
856        assert_eq!(value["path"], "src/file.ts");
857    }
858
859    #[test]
860    fn strip_root_prefix_handles_empty_string_after_strip() {
861        // Edge case: the string IS the prefix (without trailing content).
862        // This shouldn't happen in practice but should not panic.
863        let mut value = serde_json::json!("/project/");
864        strip_root_prefix(&mut value, "/project/");
865        assert_eq!(value, "");
866    }
867
868    #[test]
869    fn strip_root_prefix_deeply_nested_array_of_objects() {
870        let mut value = serde_json::json!({
871            "groups": [{
872                "instances": [{
873                    "file": "/project/src/a.ts"
874                }, {
875                    "file": "/project/src/b.ts"
876                }]
877            }]
878        });
879        strip_root_prefix(&mut value, "/project/");
880        assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
881        assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
882    }
883
884    // ── Full sample results round-trip ──────────────────────────────
885
886    #[test]
887    fn json_full_sample_results_total_issues_correct() {
888        let root = PathBuf::from("/project");
889        let results = sample_results(&root);
890        let elapsed = Duration::from_millis(100);
891        let output = build_json(&results, &root, elapsed).expect("should serialize");
892
893        // sample_results adds one of each issue type (12 total).
894        // unused_files + unused_exports + unused_types + unused_dependencies
895        // + unused_dev_dependencies + unused_enum_members + unused_class_members
896        // + unresolved_imports + unlisted_dependencies + duplicate_exports
897        // + type_only_dependencies + circular_dependencies
898        assert_eq!(output["total_issues"], results.total_issues());
899    }
900
901    #[test]
902    fn json_full_sample_no_absolute_paths_in_output() {
903        let root = PathBuf::from("/project");
904        let results = sample_results(&root);
905        let elapsed = Duration::from_millis(0);
906        let output = build_json(&results, &root, elapsed).expect("should serialize");
907
908        let json_str = serde_json::to_string(&output).expect("should stringify");
909        // The root prefix should be stripped from all paths.
910        assert!(!json_str.contains("/project/src/"));
911        assert!(!json_str.contains("/project/package.json"));
912    }
913
914    // ── JSON output is deterministic ────────────────────────────────
915
916    #[test]
917    fn json_output_is_deterministic() {
918        let root = PathBuf::from("/project");
919        let results = sample_results(&root);
920        let elapsed = Duration::from_millis(50);
921
922        let output1 = build_json(&results, &root, elapsed).expect("first build");
923        let output2 = build_json(&results, &root, elapsed).expect("second build");
924
925        assert_eq!(output1, output2);
926    }
927
928    // ── Metadata not overwritten by results fields ──────────────────
929
930    #[test]
931    fn json_results_fields_do_not_shadow_metadata() {
932        // Ensure that serialized results don't contain keys like "schema_version"
933        // that could overwrite the metadata fields we insert first.
934        let root = PathBuf::from("/project");
935        let results = AnalysisResults::default();
936        let elapsed = Duration::from_millis(99);
937        let output = build_json(&results, &root, elapsed).expect("should serialize");
938
939        // Metadata should reflect our explicit values, not anything from AnalysisResults.
940        assert_eq!(output["schema_version"], 3);
941        assert_eq!(output["elapsed_ms"], 99);
942    }
943
944    // ── All 13 issue type arrays present ────────────────────────────
945
946    #[test]
947    fn json_all_issue_type_arrays_present_in_empty_results() {
948        let root = PathBuf::from("/project");
949        let results = AnalysisResults::default();
950        let elapsed = Duration::from_millis(0);
951        let output = build_json(&results, &root, elapsed).expect("should serialize");
952
953        let expected_arrays = [
954            "unused_files",
955            "unused_exports",
956            "unused_types",
957            "unused_dependencies",
958            "unused_dev_dependencies",
959            "unused_optional_dependencies",
960            "unused_enum_members",
961            "unused_class_members",
962            "unresolved_imports",
963            "unlisted_dependencies",
964            "duplicate_exports",
965            "type_only_dependencies",
966            "circular_dependencies",
967        ];
968        for key in &expected_arrays {
969            assert!(
970                output[key].is_array(),
971                "expected '{key}' to be an array in JSON output"
972            );
973        }
974    }
975
976    // ── insert_meta ─────────────────────────────────────────────────
977
978    #[test]
979    fn insert_meta_adds_key_to_object() {
980        let mut output = serde_json::json!({ "foo": 1 });
981        let meta = serde_json::json!({ "docs": "https://example.com" });
982        insert_meta(&mut output, meta.clone());
983        assert_eq!(output["_meta"], meta);
984    }
985
986    #[test]
987    fn insert_meta_noop_on_non_object() {
988        let mut output = serde_json::json!([1, 2, 3]);
989        let meta = serde_json::json!({ "docs": "https://example.com" });
990        insert_meta(&mut output, meta);
991        // Should not panic or add anything
992        assert!(output.is_array());
993    }
994
995    #[test]
996    fn insert_meta_overwrites_existing_meta() {
997        let mut output = serde_json::json!({ "_meta": "old" });
998        let meta = serde_json::json!({ "new": true });
999        insert_meta(&mut output, meta.clone());
1000        assert_eq!(output["_meta"], meta);
1001    }
1002
1003    // ── build_json_envelope ─────────────────────────────────────────
1004
1005    #[test]
1006    fn build_json_envelope_has_metadata_fields() {
1007        let report = serde_json::json!({ "findings": [] });
1008        let elapsed = Duration::from_millis(42);
1009        let output = build_json_envelope(report, elapsed);
1010
1011        assert_eq!(output["schema_version"], 3);
1012        assert!(output["version"].is_string());
1013        assert_eq!(output["elapsed_ms"], 42);
1014        assert!(output["findings"].is_array());
1015    }
1016
1017    #[test]
1018    fn build_json_envelope_metadata_appears_first() {
1019        let report = serde_json::json!({ "data": "value" });
1020        let output = build_json_envelope(report, Duration::from_millis(10));
1021
1022        let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
1023        assert_eq!(keys[0], "schema_version");
1024        assert_eq!(keys[1], "version");
1025        assert_eq!(keys[2], "elapsed_ms");
1026    }
1027
1028    #[test]
1029    fn build_json_envelope_non_object_report() {
1030        // If report_value is not an Object, only metadata fields appear
1031        let report = serde_json::json!("not an object");
1032        let output = build_json_envelope(report, Duration::from_millis(0));
1033
1034        let obj = output.as_object().unwrap();
1035        assert_eq!(obj.len(), 3);
1036        assert!(obj.contains_key("schema_version"));
1037        assert!(obj.contains_key("version"));
1038        assert!(obj.contains_key("elapsed_ms"));
1039    }
1040
1041    // ── strip_root_prefix with null value ──
1042
1043    #[test]
1044    fn strip_root_prefix_null_unchanged() {
1045        let mut value = serde_json::Value::Null;
1046        strip_root_prefix(&mut value, "/project/");
1047        assert!(value.is_null());
1048    }
1049
1050    // ── strip_root_prefix with empty string ──
1051
1052    #[test]
1053    fn strip_root_prefix_empty_string() {
1054        let mut value = serde_json::json!("");
1055        strip_root_prefix(&mut value, "/project/");
1056        assert_eq!(value, "");
1057    }
1058
1059    // ── strip_root_prefix on mixed nested structure ──
1060
1061    #[test]
1062    fn strip_root_prefix_mixed_types() {
1063        let mut value = serde_json::json!({
1064            "path": "/project/src/file.ts",
1065            "line": 42,
1066            "flag": true,
1067            "nested": {
1068                "items": ["/project/a.ts", 99, null, "/project/b.ts"],
1069                "deep": { "path": "/project/c.ts" }
1070            }
1071        });
1072        strip_root_prefix(&mut value, "/project/");
1073        assert_eq!(value["path"], "src/file.ts");
1074        assert_eq!(value["line"], 42);
1075        assert_eq!(value["flag"], true);
1076        assert_eq!(value["nested"]["items"][0], "a.ts");
1077        assert_eq!(value["nested"]["items"][1], 99);
1078        assert!(value["nested"]["items"][2].is_null());
1079        assert_eq!(value["nested"]["items"][3], "b.ts");
1080        assert_eq!(value["nested"]["deep"]["path"], "c.ts");
1081    }
1082
1083    // ── JSON with explain meta for check ──
1084
1085    #[test]
1086    fn json_check_meta_integrates_correctly() {
1087        let root = PathBuf::from("/project");
1088        let results = AnalysisResults::default();
1089        let elapsed = Duration::from_millis(0);
1090        let mut output = build_json(&results, &root, elapsed).expect("should serialize");
1091        insert_meta(&mut output, crate::explain::check_meta());
1092
1093        assert!(output["_meta"]["docs"].is_string());
1094        assert!(output["_meta"]["rules"].is_object());
1095    }
1096
1097    // ── JSON unused member kind serialization ──
1098
1099    #[test]
1100    fn json_unused_member_kind_serialized() {
1101        let root = PathBuf::from("/project");
1102        let mut results = AnalysisResults::default();
1103        results.unused_enum_members.push(UnusedMember {
1104            path: root.join("src/enums.ts"),
1105            parent_name: "Color".to_string(),
1106            member_name: "Red".to_string(),
1107            kind: MemberKind::EnumMember,
1108            line: 3,
1109            col: 2,
1110        });
1111        results.unused_class_members.push(UnusedMember {
1112            path: root.join("src/class.ts"),
1113            parent_name: "Foo".to_string(),
1114            member_name: "bar".to_string(),
1115            kind: MemberKind::ClassMethod,
1116            line: 10,
1117            col: 4,
1118        });
1119
1120        let elapsed = Duration::from_millis(0);
1121        let output = build_json(&results, &root, elapsed).expect("should serialize");
1122
1123        let enum_member = &output["unused_enum_members"][0];
1124        assert!(enum_member["kind"].is_string());
1125        let class_member = &output["unused_class_members"][0];
1126        assert!(class_member["kind"].is_string());
1127    }
1128}