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            specifier_col: 0,
299        });
300        r.unlisted_dependencies.push(UnlistedDependency {
301            package_name: "chalk".to_string(),
302            imported_from: vec![ImportSite {
303                path: root.join("src/cli.ts"),
304                line: 2,
305                col: 0,
306            }],
307        });
308        r.duplicate_exports.push(DuplicateExport {
309            export_name: "Config".to_string(),
310            locations: vec![
311                DuplicateLocation {
312                    path: root.join("src/config.ts"),
313                    line: 15,
314                    col: 0,
315                },
316                DuplicateLocation {
317                    path: root.join("src/types.ts"),
318                    line: 30,
319                    col: 0,
320                },
321            ],
322        });
323        r.type_only_dependencies.push(TypeOnlyDependency {
324            package_name: "zod".to_string(),
325            path: root.join("package.json"),
326            line: 12,
327        });
328        r.circular_dependencies.push(CircularDependency {
329            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
330            length: 2,
331            line: 3,
332            col: 0,
333        });
334
335        r
336    }
337
338    #[test]
339    fn json_output_has_metadata_fields() {
340        let root = PathBuf::from("/project");
341        let results = AnalysisResults::default();
342        let elapsed = Duration::from_millis(123);
343        let output = build_json(&results, &root, elapsed).expect("should serialize");
344
345        assert_eq!(output["schema_version"], 3);
346        assert!(output["version"].is_string());
347        assert_eq!(output["elapsed_ms"], 123);
348        assert_eq!(output["total_issues"], 0);
349    }
350
351    #[test]
352    fn json_output_includes_issue_arrays() {
353        let root = PathBuf::from("/project");
354        let results = sample_results(&root);
355        let elapsed = Duration::from_millis(50);
356        let output = build_json(&results, &root, elapsed).expect("should serialize");
357
358        assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
359        assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
360        assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
361        assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
362        assert_eq!(
363            output["unused_dev_dependencies"].as_array().unwrap().len(),
364            1
365        );
366        assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
367        assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
368        assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
369        assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
370        assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
371        assert_eq!(
372            output["type_only_dependencies"].as_array().unwrap().len(),
373            1
374        );
375        assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
376    }
377
378    #[test]
379    fn json_metadata_fields_appear_first() {
380        let root = PathBuf::from("/project");
381        let results = AnalysisResults::default();
382        let elapsed = Duration::from_millis(0);
383        let output = build_json(&results, &root, elapsed).expect("should serialize");
384        let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
385        assert_eq!(keys[0], "schema_version");
386        assert_eq!(keys[1], "version");
387        assert_eq!(keys[2], "elapsed_ms");
388        assert_eq!(keys[3], "total_issues");
389    }
390
391    #[test]
392    fn json_total_issues_matches_results() {
393        let root = PathBuf::from("/project");
394        let results = sample_results(&root);
395        let total = results.total_issues();
396        let elapsed = Duration::from_millis(0);
397        let output = build_json(&results, &root, elapsed).expect("should serialize");
398
399        assert_eq!(output["total_issues"], total);
400    }
401
402    #[test]
403    fn json_unused_export_contains_expected_fields() {
404        let root = PathBuf::from("/project");
405        let mut results = AnalysisResults::default();
406        results.unused_exports.push(UnusedExport {
407            path: root.join("src/utils.ts"),
408            export_name: "helperFn".to_string(),
409            is_type_only: false,
410            line: 10,
411            col: 4,
412            span_start: 120,
413            is_re_export: false,
414        });
415        let elapsed = Duration::from_millis(0);
416        let output = build_json(&results, &root, elapsed).expect("should serialize");
417
418        let export = &output["unused_exports"][0];
419        assert_eq!(export["export_name"], "helperFn");
420        assert_eq!(export["line"], 10);
421        assert_eq!(export["col"], 4);
422        assert_eq!(export["is_type_only"], false);
423        assert_eq!(export["span_start"], 120);
424        assert_eq!(export["is_re_export"], false);
425    }
426
427    #[test]
428    fn json_serializes_to_valid_json() {
429        let root = PathBuf::from("/project");
430        let results = sample_results(&root);
431        let elapsed = Duration::from_millis(42);
432        let output = build_json(&results, &root, elapsed).expect("should serialize");
433
434        let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
435        let reparsed: serde_json::Value =
436            serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
437        assert_eq!(reparsed, output);
438    }
439
440    // ── Empty results ───────────────────────────────────────────────
441
442    #[test]
443    fn json_empty_results_produce_valid_structure() {
444        let root = PathBuf::from("/project");
445        let results = AnalysisResults::default();
446        let elapsed = Duration::from_millis(0);
447        let output = build_json(&results, &root, elapsed).expect("should serialize");
448
449        assert_eq!(output["total_issues"], 0);
450        assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
451        assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
452        assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
453        assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
454        assert_eq!(
455            output["unused_dev_dependencies"].as_array().unwrap().len(),
456            0
457        );
458        assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
459        assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
460        assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
461        assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
462        assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
463        assert_eq!(
464            output["type_only_dependencies"].as_array().unwrap().len(),
465            0
466        );
467        assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
468    }
469
470    #[test]
471    fn json_empty_results_round_trips_through_string() {
472        let root = PathBuf::from("/project");
473        let results = AnalysisResults::default();
474        let elapsed = Duration::from_millis(0);
475        let output = build_json(&results, &root, elapsed).expect("should serialize");
476
477        let json_str = serde_json::to_string(&output).expect("should stringify");
478        let reparsed: serde_json::Value =
479            serde_json::from_str(&json_str).expect("should parse back");
480        assert_eq!(reparsed["total_issues"], 0);
481    }
482
483    // ── Path stripping ──────────────────────────────────────────────
484
485    #[test]
486    fn json_paths_are_relative_to_root() {
487        let root = PathBuf::from("/project");
488        let mut results = AnalysisResults::default();
489        results.unused_files.push(UnusedFile {
490            path: root.join("src/deep/nested/file.ts"),
491        });
492        let elapsed = Duration::from_millis(0);
493        let output = build_json(&results, &root, elapsed).expect("should serialize");
494
495        let path = output["unused_files"][0]["path"].as_str().unwrap();
496        assert_eq!(path, "src/deep/nested/file.ts");
497        assert!(!path.starts_with("/project"));
498    }
499
500    #[test]
501    fn json_strips_root_from_nested_locations() {
502        let root = PathBuf::from("/project");
503        let mut results = AnalysisResults::default();
504        results.unlisted_dependencies.push(UnlistedDependency {
505            package_name: "chalk".to_string(),
506            imported_from: vec![ImportSite {
507                path: root.join("src/cli.ts"),
508                line: 2,
509                col: 0,
510            }],
511        });
512        let elapsed = Duration::from_millis(0);
513        let output = build_json(&results, &root, elapsed).expect("should serialize");
514
515        let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
516            .as_str()
517            .unwrap();
518        assert_eq!(site_path, "src/cli.ts");
519    }
520
521    #[test]
522    fn json_strips_root_from_duplicate_export_locations() {
523        let root = PathBuf::from("/project");
524        let mut results = AnalysisResults::default();
525        results.duplicate_exports.push(DuplicateExport {
526            export_name: "Config".to_string(),
527            locations: vec![
528                DuplicateLocation {
529                    path: root.join("src/config.ts"),
530                    line: 15,
531                    col: 0,
532                },
533                DuplicateLocation {
534                    path: root.join("src/types.ts"),
535                    line: 30,
536                    col: 0,
537                },
538            ],
539        });
540        let elapsed = Duration::from_millis(0);
541        let output = build_json(&results, &root, elapsed).expect("should serialize");
542
543        let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
544            .as_str()
545            .unwrap();
546        let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
547            .as_str()
548            .unwrap();
549        assert_eq!(loc0, "src/config.ts");
550        assert_eq!(loc1, "src/types.ts");
551    }
552
553    #[test]
554    fn json_strips_root_from_circular_dependency_files() {
555        let root = PathBuf::from("/project");
556        let mut results = AnalysisResults::default();
557        results.circular_dependencies.push(CircularDependency {
558            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
559            length: 2,
560            line: 1,
561            col: 0,
562        });
563        let elapsed = Duration::from_millis(0);
564        let output = build_json(&results, &root, elapsed).expect("should serialize");
565
566        let files = output["circular_dependencies"][0]["files"]
567            .as_array()
568            .unwrap();
569        assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
570        assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
571    }
572
573    #[test]
574    fn json_path_outside_root_not_stripped() {
575        let root = PathBuf::from("/project");
576        let mut results = AnalysisResults::default();
577        results.unused_files.push(UnusedFile {
578            path: PathBuf::from("/other/project/src/file.ts"),
579        });
580        let elapsed = Duration::from_millis(0);
581        let output = build_json(&results, &root, elapsed).expect("should serialize");
582
583        let path = output["unused_files"][0]["path"].as_str().unwrap();
584        assert!(path.contains("/other/project/"));
585    }
586
587    // ── Individual issue type field verification ────────────────────
588
589    #[test]
590    fn json_unused_file_contains_path() {
591        let root = PathBuf::from("/project");
592        let mut results = AnalysisResults::default();
593        results.unused_files.push(UnusedFile {
594            path: root.join("src/orphan.ts"),
595        });
596        let elapsed = Duration::from_millis(0);
597        let output = build_json(&results, &root, elapsed).expect("should serialize");
598
599        let file = &output["unused_files"][0];
600        assert_eq!(file["path"], "src/orphan.ts");
601    }
602
603    #[test]
604    fn json_unused_type_contains_expected_fields() {
605        let root = PathBuf::from("/project");
606        let mut results = AnalysisResults::default();
607        results.unused_types.push(UnusedExport {
608            path: root.join("src/types.ts"),
609            export_name: "OldInterface".to_string(),
610            is_type_only: true,
611            line: 20,
612            col: 0,
613            span_start: 300,
614            is_re_export: false,
615        });
616        let elapsed = Duration::from_millis(0);
617        let output = build_json(&results, &root, elapsed).expect("should serialize");
618
619        let typ = &output["unused_types"][0];
620        assert_eq!(typ["export_name"], "OldInterface");
621        assert_eq!(typ["is_type_only"], true);
622        assert_eq!(typ["line"], 20);
623        assert_eq!(typ["path"], "src/types.ts");
624    }
625
626    #[test]
627    fn json_unused_dependency_contains_expected_fields() {
628        let root = PathBuf::from("/project");
629        let mut results = AnalysisResults::default();
630        results.unused_dependencies.push(UnusedDependency {
631            package_name: "axios".to_string(),
632            location: DependencyLocation::Dependencies,
633            path: root.join("package.json"),
634            line: 10,
635        });
636        let elapsed = Duration::from_millis(0);
637        let output = build_json(&results, &root, elapsed).expect("should serialize");
638
639        let dep = &output["unused_dependencies"][0];
640        assert_eq!(dep["package_name"], "axios");
641        assert_eq!(dep["line"], 10);
642    }
643
644    #[test]
645    fn json_unused_dev_dependency_contains_expected_fields() {
646        let root = PathBuf::from("/project");
647        let mut results = AnalysisResults::default();
648        results.unused_dev_dependencies.push(UnusedDependency {
649            package_name: "vitest".to_string(),
650            location: DependencyLocation::DevDependencies,
651            path: root.join("package.json"),
652            line: 15,
653        });
654        let elapsed = Duration::from_millis(0);
655        let output = build_json(&results, &root, elapsed).expect("should serialize");
656
657        let dep = &output["unused_dev_dependencies"][0];
658        assert_eq!(dep["package_name"], "vitest");
659    }
660
661    #[test]
662    fn json_unused_optional_dependency_contains_expected_fields() {
663        let root = PathBuf::from("/project");
664        let mut results = AnalysisResults::default();
665        results.unused_optional_dependencies.push(UnusedDependency {
666            package_name: "fsevents".to_string(),
667            location: DependencyLocation::OptionalDependencies,
668            path: root.join("package.json"),
669            line: 12,
670        });
671        let elapsed = Duration::from_millis(0);
672        let output = build_json(&results, &root, elapsed).expect("should serialize");
673
674        let dep = &output["unused_optional_dependencies"][0];
675        assert_eq!(dep["package_name"], "fsevents");
676        assert_eq!(output["total_issues"], 1);
677    }
678
679    #[test]
680    fn json_unused_enum_member_contains_expected_fields() {
681        let root = PathBuf::from("/project");
682        let mut results = AnalysisResults::default();
683        results.unused_enum_members.push(UnusedMember {
684            path: root.join("src/enums.ts"),
685            parent_name: "Color".to_string(),
686            member_name: "Purple".to_string(),
687            kind: MemberKind::EnumMember,
688            line: 5,
689            col: 2,
690        });
691        let elapsed = Duration::from_millis(0);
692        let output = build_json(&results, &root, elapsed).expect("should serialize");
693
694        let member = &output["unused_enum_members"][0];
695        assert_eq!(member["parent_name"], "Color");
696        assert_eq!(member["member_name"], "Purple");
697        assert_eq!(member["line"], 5);
698        assert_eq!(member["path"], "src/enums.ts");
699    }
700
701    #[test]
702    fn json_unused_class_member_contains_expected_fields() {
703        let root = PathBuf::from("/project");
704        let mut results = AnalysisResults::default();
705        results.unused_class_members.push(UnusedMember {
706            path: root.join("src/api.ts"),
707            parent_name: "ApiClient".to_string(),
708            member_name: "deprecatedFetch".to_string(),
709            kind: MemberKind::ClassMethod,
710            line: 100,
711            col: 4,
712        });
713        let elapsed = Duration::from_millis(0);
714        let output = build_json(&results, &root, elapsed).expect("should serialize");
715
716        let member = &output["unused_class_members"][0];
717        assert_eq!(member["parent_name"], "ApiClient");
718        assert_eq!(member["member_name"], "deprecatedFetch");
719        assert_eq!(member["line"], 100);
720    }
721
722    #[test]
723    fn json_unresolved_import_contains_expected_fields() {
724        let root = PathBuf::from("/project");
725        let mut results = AnalysisResults::default();
726        results.unresolved_imports.push(UnresolvedImport {
727            path: root.join("src/app.ts"),
728            specifier: "@acme/missing-pkg".to_string(),
729            line: 7,
730            col: 0,
731            specifier_col: 0,
732        });
733        let elapsed = Duration::from_millis(0);
734        let output = build_json(&results, &root, elapsed).expect("should serialize");
735
736        let import = &output["unresolved_imports"][0];
737        assert_eq!(import["specifier"], "@acme/missing-pkg");
738        assert_eq!(import["line"], 7);
739        assert_eq!(import["path"], "src/app.ts");
740    }
741
742    #[test]
743    fn json_unlisted_dependency_contains_import_sites() {
744        let root = PathBuf::from("/project");
745        let mut results = AnalysisResults::default();
746        results.unlisted_dependencies.push(UnlistedDependency {
747            package_name: "dotenv".to_string(),
748            imported_from: vec![
749                ImportSite {
750                    path: root.join("src/config.ts"),
751                    line: 1,
752                    col: 0,
753                },
754                ImportSite {
755                    path: root.join("src/server.ts"),
756                    line: 3,
757                    col: 0,
758                },
759            ],
760        });
761        let elapsed = Duration::from_millis(0);
762        let output = build_json(&results, &root, elapsed).expect("should serialize");
763
764        let dep = &output["unlisted_dependencies"][0];
765        assert_eq!(dep["package_name"], "dotenv");
766        let sites = dep["imported_from"].as_array().unwrap();
767        assert_eq!(sites.len(), 2);
768        assert_eq!(sites[0]["path"], "src/config.ts");
769        assert_eq!(sites[1]["path"], "src/server.ts");
770    }
771
772    #[test]
773    fn json_duplicate_export_contains_locations() {
774        let root = PathBuf::from("/project");
775        let mut results = AnalysisResults::default();
776        results.duplicate_exports.push(DuplicateExport {
777            export_name: "Button".to_string(),
778            locations: vec![
779                DuplicateLocation {
780                    path: root.join("src/ui.ts"),
781                    line: 10,
782                    col: 0,
783                },
784                DuplicateLocation {
785                    path: root.join("src/components.ts"),
786                    line: 25,
787                    col: 0,
788                },
789            ],
790        });
791        let elapsed = Duration::from_millis(0);
792        let output = build_json(&results, &root, elapsed).expect("should serialize");
793
794        let dup = &output["duplicate_exports"][0];
795        assert_eq!(dup["export_name"], "Button");
796        let locs = dup["locations"].as_array().unwrap();
797        assert_eq!(locs.len(), 2);
798        assert_eq!(locs[0]["line"], 10);
799        assert_eq!(locs[1]["line"], 25);
800    }
801
802    #[test]
803    fn json_type_only_dependency_contains_expected_fields() {
804        let root = PathBuf::from("/project");
805        let mut results = AnalysisResults::default();
806        results.type_only_dependencies.push(TypeOnlyDependency {
807            package_name: "zod".to_string(),
808            path: root.join("package.json"),
809            line: 8,
810        });
811        let elapsed = Duration::from_millis(0);
812        let output = build_json(&results, &root, elapsed).expect("should serialize");
813
814        let dep = &output["type_only_dependencies"][0];
815        assert_eq!(dep["package_name"], "zod");
816        assert_eq!(dep["line"], 8);
817    }
818
819    #[test]
820    fn json_circular_dependency_contains_expected_fields() {
821        let root = PathBuf::from("/project");
822        let mut results = AnalysisResults::default();
823        results.circular_dependencies.push(CircularDependency {
824            files: vec![
825                root.join("src/a.ts"),
826                root.join("src/b.ts"),
827                root.join("src/c.ts"),
828            ],
829            length: 3,
830            line: 5,
831            col: 0,
832        });
833        let elapsed = Duration::from_millis(0);
834        let output = build_json(&results, &root, elapsed).expect("should serialize");
835
836        let cycle = &output["circular_dependencies"][0];
837        assert_eq!(cycle["length"], 3);
838        assert_eq!(cycle["line"], 5);
839        let files = cycle["files"].as_array().unwrap();
840        assert_eq!(files.len(), 3);
841    }
842
843    // ── Re-export tagging ───────────────────────────────────────────
844
845    #[test]
846    fn json_re_export_flagged_correctly() {
847        let root = PathBuf::from("/project");
848        let mut results = AnalysisResults::default();
849        results.unused_exports.push(UnusedExport {
850            path: root.join("src/index.ts"),
851            export_name: "reExported".to_string(),
852            is_type_only: false,
853            line: 1,
854            col: 0,
855            span_start: 0,
856            is_re_export: true,
857        });
858        let elapsed = Duration::from_millis(0);
859        let output = build_json(&results, &root, elapsed).expect("should serialize");
860
861        assert_eq!(output["unused_exports"][0]["is_re_export"], true);
862    }
863
864    // ── Schema version stability ────────────────────────────────────
865
866    #[test]
867    fn json_schema_version_is_3() {
868        let root = PathBuf::from("/project");
869        let results = AnalysisResults::default();
870        let elapsed = Duration::from_millis(0);
871        let output = build_json(&results, &root, elapsed).expect("should serialize");
872
873        assert_eq!(output["schema_version"], SCHEMA_VERSION);
874        assert_eq!(output["schema_version"], 3);
875    }
876
877    // ── Version string ──────────────────────────────────────────────
878
879    #[test]
880    fn json_version_matches_cargo_pkg_version() {
881        let root = PathBuf::from("/project");
882        let results = AnalysisResults::default();
883        let elapsed = Duration::from_millis(0);
884        let output = build_json(&results, &root, elapsed).expect("should serialize");
885
886        assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
887    }
888
889    // ── Elapsed time encoding ───────────────────────────────────────
890
891    #[test]
892    fn json_elapsed_ms_zero_duration() {
893        let root = PathBuf::from("/project");
894        let results = AnalysisResults::default();
895        let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
896
897        assert_eq!(output["elapsed_ms"], 0);
898    }
899
900    #[test]
901    fn json_elapsed_ms_large_duration() {
902        let root = PathBuf::from("/project");
903        let results = AnalysisResults::default();
904        let elapsed = Duration::from_secs(120);
905        let output = build_json(&results, &root, elapsed).expect("should serialize");
906
907        assert_eq!(output["elapsed_ms"], 120_000);
908    }
909
910    #[test]
911    fn json_elapsed_ms_sub_millisecond_truncated() {
912        let root = PathBuf::from("/project");
913        let results = AnalysisResults::default();
914        // 500 microseconds = 0 milliseconds (truncated)
915        let elapsed = Duration::from_micros(500);
916        let output = build_json(&results, &root, elapsed).expect("should serialize");
917
918        assert_eq!(output["elapsed_ms"], 0);
919    }
920
921    // ── Multiple issues of same type ────────────────────────────────
922
923    #[test]
924    fn json_multiple_unused_files() {
925        let root = PathBuf::from("/project");
926        let mut results = AnalysisResults::default();
927        results.unused_files.push(UnusedFile {
928            path: root.join("src/a.ts"),
929        });
930        results.unused_files.push(UnusedFile {
931            path: root.join("src/b.ts"),
932        });
933        results.unused_files.push(UnusedFile {
934            path: root.join("src/c.ts"),
935        });
936        let elapsed = Duration::from_millis(0);
937        let output = build_json(&results, &root, elapsed).expect("should serialize");
938
939        assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
940        assert_eq!(output["total_issues"], 3);
941    }
942
943    // ── strip_root_prefix unit tests ────────────────────────────────
944
945    #[test]
946    fn strip_root_prefix_on_string_value() {
947        let mut value = serde_json::json!("/project/src/file.ts");
948        strip_root_prefix(&mut value, "/project/");
949        assert_eq!(value, "src/file.ts");
950    }
951
952    #[test]
953    fn strip_root_prefix_leaves_non_matching_string() {
954        let mut value = serde_json::json!("/other/src/file.ts");
955        strip_root_prefix(&mut value, "/project/");
956        assert_eq!(value, "/other/src/file.ts");
957    }
958
959    #[test]
960    fn strip_root_prefix_recurses_into_arrays() {
961        let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
962        strip_root_prefix(&mut value, "/project/");
963        assert_eq!(value[0], "a.ts");
964        assert_eq!(value[1], "b.ts");
965        assert_eq!(value[2], "/other/c.ts");
966    }
967
968    #[test]
969    fn strip_root_prefix_recurses_into_nested_objects() {
970        let mut value = serde_json::json!({
971            "outer": {
972                "path": "/project/src/nested.ts"
973            }
974        });
975        strip_root_prefix(&mut value, "/project/");
976        assert_eq!(value["outer"]["path"], "src/nested.ts");
977    }
978
979    #[test]
980    fn strip_root_prefix_leaves_numbers_and_booleans() {
981        let mut value = serde_json::json!({
982            "line": 42,
983            "is_type_only": false,
984            "path": "/project/src/file.ts"
985        });
986        strip_root_prefix(&mut value, "/project/");
987        assert_eq!(value["line"], 42);
988        assert_eq!(value["is_type_only"], false);
989        assert_eq!(value["path"], "src/file.ts");
990    }
991
992    #[test]
993    fn strip_root_prefix_handles_empty_string_after_strip() {
994        // Edge case: the string IS the prefix (without trailing content).
995        // This shouldn't happen in practice but should not panic.
996        let mut value = serde_json::json!("/project/");
997        strip_root_prefix(&mut value, "/project/");
998        assert_eq!(value, "");
999    }
1000
1001    #[test]
1002    fn strip_root_prefix_deeply_nested_array_of_objects() {
1003        let mut value = serde_json::json!({
1004            "groups": [{
1005                "instances": [{
1006                    "file": "/project/src/a.ts"
1007                }, {
1008                    "file": "/project/src/b.ts"
1009                }]
1010            }]
1011        });
1012        strip_root_prefix(&mut value, "/project/");
1013        assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
1014        assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
1015    }
1016
1017    // ── Full sample results round-trip ──────────────────────────────
1018
1019    #[test]
1020    fn json_full_sample_results_total_issues_correct() {
1021        let root = PathBuf::from("/project");
1022        let results = sample_results(&root);
1023        let elapsed = Duration::from_millis(100);
1024        let output = build_json(&results, &root, elapsed).expect("should serialize");
1025
1026        // sample_results adds one of each issue type (12 total).
1027        // unused_files + unused_exports + unused_types + unused_dependencies
1028        // + unused_dev_dependencies + unused_enum_members + unused_class_members
1029        // + unresolved_imports + unlisted_dependencies + duplicate_exports
1030        // + type_only_dependencies + circular_dependencies
1031        assert_eq!(output["total_issues"], results.total_issues());
1032    }
1033
1034    #[test]
1035    fn json_full_sample_no_absolute_paths_in_output() {
1036        let root = PathBuf::from("/project");
1037        let results = sample_results(&root);
1038        let elapsed = Duration::from_millis(0);
1039        let output = build_json(&results, &root, elapsed).expect("should serialize");
1040
1041        let json_str = serde_json::to_string(&output).expect("should stringify");
1042        // The root prefix should be stripped from all paths.
1043        assert!(!json_str.contains("/project/src/"));
1044        assert!(!json_str.contains("/project/package.json"));
1045    }
1046
1047    // ── JSON output is deterministic ────────────────────────────────
1048
1049    #[test]
1050    fn json_output_is_deterministic() {
1051        let root = PathBuf::from("/project");
1052        let results = sample_results(&root);
1053        let elapsed = Duration::from_millis(50);
1054
1055        let output1 = build_json(&results, &root, elapsed).expect("first build");
1056        let output2 = build_json(&results, &root, elapsed).expect("second build");
1057
1058        assert_eq!(output1, output2);
1059    }
1060
1061    // ── Metadata not overwritten by results fields ──────────────────
1062
1063    #[test]
1064    fn json_results_fields_do_not_shadow_metadata() {
1065        // Ensure that serialized results don't contain keys like "schema_version"
1066        // that could overwrite the metadata fields we insert first.
1067        let root = PathBuf::from("/project");
1068        let results = AnalysisResults::default();
1069        let elapsed = Duration::from_millis(99);
1070        let output = build_json(&results, &root, elapsed).expect("should serialize");
1071
1072        // Metadata should reflect our explicit values, not anything from AnalysisResults.
1073        assert_eq!(output["schema_version"], 3);
1074        assert_eq!(output["elapsed_ms"], 99);
1075    }
1076
1077    // ── All 13 issue type arrays present ────────────────────────────
1078
1079    #[test]
1080    fn json_all_issue_type_arrays_present_in_empty_results() {
1081        let root = PathBuf::from("/project");
1082        let results = AnalysisResults::default();
1083        let elapsed = Duration::from_millis(0);
1084        let output = build_json(&results, &root, elapsed).expect("should serialize");
1085
1086        let expected_arrays = [
1087            "unused_files",
1088            "unused_exports",
1089            "unused_types",
1090            "unused_dependencies",
1091            "unused_dev_dependencies",
1092            "unused_optional_dependencies",
1093            "unused_enum_members",
1094            "unused_class_members",
1095            "unresolved_imports",
1096            "unlisted_dependencies",
1097            "duplicate_exports",
1098            "type_only_dependencies",
1099            "circular_dependencies",
1100        ];
1101        for key in &expected_arrays {
1102            assert!(
1103                output[key].is_array(),
1104                "expected '{key}' to be an array in JSON output"
1105            );
1106        }
1107    }
1108}