Skip to main content

fallow_cli/report/
json.rs

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