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