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