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`.
75///
76/// # Errors
77///
78/// Returns an error if the results cannot be serialized to JSON.
79pub fn build_json(
80    results: &AnalysisResults,
81    root: &Path,
82    elapsed: Duration,
83) -> Result<serde_json::Value, serde_json::Error> {
84    let results_value = serde_json::to_value(results)?;
85
86    let mut map = serde_json::Map::new();
87    map.insert(
88        "schema_version".to_string(),
89        serde_json::json!(SCHEMA_VERSION),
90    );
91    map.insert(
92        "version".to_string(),
93        serde_json::json!(env!("CARGO_PKG_VERSION")),
94    );
95    map.insert(
96        "elapsed_ms".to_string(),
97        serde_json::json!(elapsed.as_millis()),
98    );
99    map.insert(
100        "total_issues".to_string(),
101        serde_json::json!(results.total_issues()),
102    );
103
104    if let serde_json::Value::Object(results_map) = results_value {
105        for (key, value) in results_map {
106            map.insert(key, value);
107        }
108    }
109
110    let mut output = serde_json::Value::Object(map);
111    let root_prefix = format!("{}/", root.display());
112    // strip_root_prefix must run before inject_actions so that injected
113    // action fields (static strings and package names) are not processed
114    // by the path stripper.
115    strip_root_prefix(&mut output, &root_prefix);
116    inject_actions(&mut output);
117    Ok(output)
118}
119
120/// Recursively strip the root prefix from all string values in the JSON tree.
121///
122/// This converts absolute paths (e.g., `/home/runner/work/repo/repo/src/utils.ts`)
123/// to relative paths (`src/utils.ts`) for all output fields.
124fn strip_root_prefix(value: &mut serde_json::Value, prefix: &str) {
125    match value {
126        serde_json::Value::String(s) => {
127            if let Some(rest) = s.strip_prefix(prefix) {
128                *s = rest.to_string();
129            }
130        }
131        serde_json::Value::Array(arr) => {
132            for item in arr {
133                strip_root_prefix(item, prefix);
134            }
135        }
136        serde_json::Value::Object(map) => {
137            for (_, v) in map.iter_mut() {
138                strip_root_prefix(v, prefix);
139            }
140        }
141        _ => {}
142    }
143}
144
145// ── Fix action injection ────────────────────────────────────────
146
147/// Suppress mechanism for an issue type.
148enum SuppressKind {
149    /// `// fallow-ignore-next-line <type>` on the line before.
150    InlineComment,
151    /// `// fallow-ignore-file <type>` at the top of the file.
152    FileComment,
153    /// Add to `ignoreDependencies` in fallow config.
154    ConfigIgnoreDep,
155}
156
157/// Specification for actions to inject per issue type.
158struct ActionSpec {
159    fix_type: &'static str,
160    auto_fixable: bool,
161    description: &'static str,
162    note: Option<&'static str>,
163    suppress: SuppressKind,
164    issue_kind: &'static str,
165}
166
167/// Map an issue array key to its action specification.
168fn actions_for_issue_type(key: &str) -> Option<ActionSpec> {
169    match key {
170        "unused_files" => Some(ActionSpec {
171            fix_type: "delete-file",
172            auto_fixable: false,
173            description: "Delete this file",
174            note: Some(
175                "File deletion may remove runtime functionality not visible to static analysis",
176            ),
177            suppress: SuppressKind::FileComment,
178            issue_kind: "unused-file",
179        }),
180        "unused_exports" => Some(ActionSpec {
181            fix_type: "remove-export",
182            auto_fixable: true,
183            description: "Remove the `export` keyword from the declaration",
184            note: None,
185            suppress: SuppressKind::InlineComment,
186            issue_kind: "unused-export",
187        }),
188        "unused_types" => Some(ActionSpec {
189            fix_type: "remove-export",
190            auto_fixable: true,
191            description: "Remove the `export` (or `export type`) keyword from the type declaration",
192            note: None,
193            suppress: SuppressKind::InlineComment,
194            issue_kind: "unused-type",
195        }),
196        "unused_dependencies" => Some(ActionSpec {
197            fix_type: "remove-dependency",
198            auto_fixable: true,
199            description: "Remove from dependencies in package.json",
200            note: None,
201            suppress: SuppressKind::ConfigIgnoreDep,
202            issue_kind: "unused-dependency",
203        }),
204        "unused_dev_dependencies" => Some(ActionSpec {
205            fix_type: "remove-dependency",
206            auto_fixable: true,
207            description: "Remove from devDependencies in package.json",
208            note: None,
209            suppress: SuppressKind::ConfigIgnoreDep,
210            issue_kind: "unused-dev-dependency",
211        }),
212        "unused_optional_dependencies" => Some(ActionSpec {
213            fix_type: "remove-dependency",
214            auto_fixable: true,
215            description: "Remove from optionalDependencies in package.json",
216            note: None,
217            suppress: SuppressKind::ConfigIgnoreDep,
218            // No IssueKind variant exists for optional deps — uses config suppress only.
219            issue_kind: "unused-dependency",
220        }),
221        "unused_enum_members" => Some(ActionSpec {
222            fix_type: "remove-enum-member",
223            auto_fixable: true,
224            description: "Remove this enum member",
225            note: None,
226            suppress: SuppressKind::InlineComment,
227            issue_kind: "unused-enum-member",
228        }),
229        "unused_class_members" => Some(ActionSpec {
230            fix_type: "remove-class-member",
231            auto_fixable: false,
232            description: "Remove this class member",
233            note: Some("Class member may be used via dependency injection or decorators"),
234            suppress: SuppressKind::InlineComment,
235            issue_kind: "unused-class-member",
236        }),
237        "unresolved_imports" => Some(ActionSpec {
238            fix_type: "resolve-import",
239            auto_fixable: false,
240            description: "Fix the import specifier or install the missing module",
241            note: Some("Verify the module path and check tsconfig paths configuration"),
242            suppress: SuppressKind::InlineComment,
243            issue_kind: "unresolved-import",
244        }),
245        "unlisted_dependencies" => Some(ActionSpec {
246            fix_type: "install-dependency",
247            auto_fixable: false,
248            description: "Add this package to dependencies in package.json",
249            note: Some("Verify this package should be a direct dependency before adding"),
250            suppress: SuppressKind::ConfigIgnoreDep,
251            issue_kind: "unlisted-dependency",
252        }),
253        "duplicate_exports" => Some(ActionSpec {
254            fix_type: "remove-duplicate",
255            auto_fixable: false,
256            description: "Keep one canonical export location and remove the others",
257            note: Some("Review all locations to determine which should be the canonical export"),
258            suppress: SuppressKind::InlineComment,
259            issue_kind: "duplicate-export",
260        }),
261        "type_only_dependencies" => Some(ActionSpec {
262            fix_type: "move-to-dev",
263            auto_fixable: false,
264            description: "Move to devDependencies (only type imports are used)",
265            note: Some(
266                "Type imports are erased at runtime so this dependency is not needed in production",
267            ),
268            suppress: SuppressKind::ConfigIgnoreDep,
269            issue_kind: "type-only-dependency",
270        }),
271        "test_only_dependencies" => Some(ActionSpec {
272            fix_type: "move-to-dev",
273            auto_fixable: false,
274            description: "Move to devDependencies (only test files import this)",
275            note: Some(
276                "Only test files import this package so it does not need to be a production dependency",
277            ),
278            suppress: SuppressKind::ConfigIgnoreDep,
279            issue_kind: "test-only-dependency",
280        }),
281        "circular_dependencies" => Some(ActionSpec {
282            fix_type: "refactor-cycle",
283            auto_fixable: false,
284            description: "Extract shared logic into a separate module to break the cycle",
285            note: Some(
286                "Circular imports can cause initialization issues and make code harder to reason about",
287            ),
288            suppress: SuppressKind::InlineComment,
289            issue_kind: "circular-dependency",
290        }),
291        "boundary_violations" => Some(ActionSpec {
292            fix_type: "refactor-boundary",
293            auto_fixable: false,
294            description: "Move the import through an allowed zone or restructure the dependency",
295            note: Some(
296                "This import crosses an architecture boundary that is not permitted by the configured rules",
297            ),
298            suppress: SuppressKind::InlineComment,
299            issue_kind: "boundary-violation",
300        }),
301        _ => None,
302    }
303}
304
305/// Build the `actions` array for a single issue item.
306fn build_actions(
307    item: &serde_json::Value,
308    issue_key: &str,
309    spec: &ActionSpec,
310) -> serde_json::Value {
311    let mut actions = Vec::with_capacity(2);
312
313    // Primary fix action
314    let mut fix_action = serde_json::json!({
315        "type": spec.fix_type,
316        "auto_fixable": spec.auto_fixable,
317        "description": spec.description,
318    });
319    if let Some(note) = spec.note {
320        fix_action["note"] = serde_json::json!(note);
321    }
322    // Warn about re-exports that may be part of the public API surface.
323    if (issue_key == "unused_exports" || issue_key == "unused_types")
324        && item
325            .get("is_re_export")
326            .and_then(serde_json::Value::as_bool)
327            == Some(true)
328    {
329        fix_action["note"] = serde_json::json!(
330            "This finding originates from a re-export; verify it is not part of your public API before removing"
331        );
332    }
333    actions.push(fix_action);
334
335    // Suppress action — every action carries `auto_fixable` for uniform filtering.
336    match spec.suppress {
337        SuppressKind::InlineComment => {
338            let mut suppress = serde_json::json!({
339                "type": "suppress-line",
340                "auto_fixable": false,
341                "description": "Suppress with an inline comment above the line",
342                "comment": format!("// fallow-ignore-next-line {}", spec.issue_kind),
343            });
344            // duplicate_exports has N locations, not one — flag multi-location scope.
345            if issue_key == "duplicate_exports" {
346                suppress["scope"] = serde_json::json!("per-location");
347            }
348            actions.push(suppress);
349        }
350        SuppressKind::FileComment => {
351            actions.push(serde_json::json!({
352                "type": "suppress-file",
353                "auto_fixable": false,
354                "description": "Suppress with a file-level comment at the top of the file",
355                "comment": format!("// fallow-ignore-file {}", spec.issue_kind),
356            }));
357        }
358        SuppressKind::ConfigIgnoreDep => {
359            // Extract the package name from the item for a concrete suggestion.
360            let pkg = item
361                .get("package_name")
362                .and_then(serde_json::Value::as_str)
363                .unwrap_or("package-name");
364            actions.push(serde_json::json!({
365                "type": "add-to-config",
366                "auto_fixable": false,
367                "description": format!("Add \"{pkg}\" to ignoreDependencies in fallow config"),
368                "config_key": "ignoreDependencies",
369                "value": pkg,
370            }));
371        }
372    }
373
374    serde_json::Value::Array(actions)
375}
376
377/// Inject `actions` arrays into every issue item in the JSON output.
378///
379/// Walks each known issue-type array and appends an `actions` field
380/// to every item, providing machine-actionable fix and suppress hints.
381fn inject_actions(output: &mut serde_json::Value) {
382    let Some(map) = output.as_object_mut() else {
383        return;
384    };
385
386    for (key, value) in map.iter_mut() {
387        let Some(spec) = actions_for_issue_type(key) else {
388            continue;
389        };
390        let Some(arr) = value.as_array_mut() else {
391            continue;
392        };
393        for item in arr {
394            let actions = build_actions(item, key, &spec);
395            if let serde_json::Value::Object(obj) = item {
396                obj.insert("actions".to_string(), actions);
397            }
398        }
399    }
400}
401
402/// Insert a `_meta` key into a JSON object value.
403fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
404    if let serde_json::Value::Object(map) = output {
405        map.insert("_meta".to_string(), meta);
406    }
407}
408
409pub(super) fn print_health_json(
410    report: &crate::health_types::HealthReport,
411    root: &Path,
412    elapsed: Duration,
413    explain: bool,
414) -> ExitCode {
415    let report_value = match serde_json::to_value(report) {
416        Ok(v) => v,
417        Err(e) => {
418            eprintln!("Error: failed to serialize health report: {e}");
419            return ExitCode::from(2);
420        }
421    };
422
423    let mut output = build_json_envelope(report_value, elapsed);
424    let root_prefix = format!("{}/", root.display());
425    strip_root_prefix(&mut output, &root_prefix);
426
427    if explain {
428        insert_meta(&mut output, explain::health_meta());
429    }
430
431    emit_json(&output, "JSON")
432}
433
434pub(super) fn print_duplication_json(
435    report: &DuplicationReport,
436    elapsed: Duration,
437    explain: bool,
438) -> ExitCode {
439    let report_value = match serde_json::to_value(report) {
440        Ok(v) => v,
441        Err(e) => {
442            eprintln!("Error: failed to serialize duplication report: {e}");
443            return ExitCode::from(2);
444        }
445    };
446
447    let mut output = build_json_envelope(report_value, elapsed);
448
449    if explain {
450        insert_meta(&mut output, explain::dupes_meta());
451    }
452
453    emit_json(&output, "JSON")
454}
455
456pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
457    match serde_json::to_string_pretty(value) {
458        Ok(json) => println!("{json}"),
459        Err(e) => {
460            eprintln!("Error: failed to serialize trace output: {e}");
461            #[expect(
462                clippy::exit,
463                reason = "fatal serialization error requires immediate exit"
464            )]
465            std::process::exit(2);
466        }
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use crate::report::test_helpers::sample_results;
474    use fallow_core::extract::MemberKind;
475    use fallow_core::results::*;
476    use std::path::PathBuf;
477    use std::time::Duration;
478
479    #[test]
480    fn json_output_has_metadata_fields() {
481        let root = PathBuf::from("/project");
482        let results = AnalysisResults::default();
483        let elapsed = Duration::from_millis(123);
484        let output = build_json(&results, &root, elapsed).expect("should serialize");
485
486        assert_eq!(output["schema_version"], 3);
487        assert!(output["version"].is_string());
488        assert_eq!(output["elapsed_ms"], 123);
489        assert_eq!(output["total_issues"], 0);
490    }
491
492    #[test]
493    fn json_output_includes_issue_arrays() {
494        let root = PathBuf::from("/project");
495        let results = sample_results(&root);
496        let elapsed = Duration::from_millis(50);
497        let output = build_json(&results, &root, elapsed).expect("should serialize");
498
499        assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
500        assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
501        assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
502        assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
503        assert_eq!(
504            output["unused_dev_dependencies"].as_array().unwrap().len(),
505            1
506        );
507        assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
508        assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
509        assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
510        assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
511        assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
512        assert_eq!(
513            output["type_only_dependencies"].as_array().unwrap().len(),
514            1
515        );
516        assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
517    }
518
519    #[test]
520    fn json_metadata_fields_appear_first() {
521        let root = PathBuf::from("/project");
522        let results = AnalysisResults::default();
523        let elapsed = Duration::from_millis(0);
524        let output = build_json(&results, &root, elapsed).expect("should serialize");
525        let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
526        assert_eq!(keys[0], "schema_version");
527        assert_eq!(keys[1], "version");
528        assert_eq!(keys[2], "elapsed_ms");
529        assert_eq!(keys[3], "total_issues");
530    }
531
532    #[test]
533    fn json_total_issues_matches_results() {
534        let root = PathBuf::from("/project");
535        let results = sample_results(&root);
536        let total = results.total_issues();
537        let elapsed = Duration::from_millis(0);
538        let output = build_json(&results, &root, elapsed).expect("should serialize");
539
540        assert_eq!(output["total_issues"], total);
541    }
542
543    #[test]
544    fn json_unused_export_contains_expected_fields() {
545        let root = PathBuf::from("/project");
546        let mut results = AnalysisResults::default();
547        results.unused_exports.push(UnusedExport {
548            path: root.join("src/utils.ts"),
549            export_name: "helperFn".to_string(),
550            is_type_only: false,
551            line: 10,
552            col: 4,
553            span_start: 120,
554            is_re_export: false,
555        });
556        let elapsed = Duration::from_millis(0);
557        let output = build_json(&results, &root, elapsed).expect("should serialize");
558
559        let export = &output["unused_exports"][0];
560        assert_eq!(export["export_name"], "helperFn");
561        assert_eq!(export["line"], 10);
562        assert_eq!(export["col"], 4);
563        assert_eq!(export["is_type_only"], false);
564        assert_eq!(export["span_start"], 120);
565        assert_eq!(export["is_re_export"], false);
566    }
567
568    #[test]
569    fn json_serializes_to_valid_json() {
570        let root = PathBuf::from("/project");
571        let results = sample_results(&root);
572        let elapsed = Duration::from_millis(42);
573        let output = build_json(&results, &root, elapsed).expect("should serialize");
574
575        let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
576        let reparsed: serde_json::Value =
577            serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
578        assert_eq!(reparsed, output);
579    }
580
581    // ── Empty results ───────────────────────────────────────────────
582
583    #[test]
584    fn json_empty_results_produce_valid_structure() {
585        let root = PathBuf::from("/project");
586        let results = AnalysisResults::default();
587        let elapsed = Duration::from_millis(0);
588        let output = build_json(&results, &root, elapsed).expect("should serialize");
589
590        assert_eq!(output["total_issues"], 0);
591        assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
592        assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
593        assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
594        assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
595        assert_eq!(
596            output["unused_dev_dependencies"].as_array().unwrap().len(),
597            0
598        );
599        assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
600        assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
601        assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
602        assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
603        assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
604        assert_eq!(
605            output["type_only_dependencies"].as_array().unwrap().len(),
606            0
607        );
608        assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
609    }
610
611    #[test]
612    fn json_empty_results_round_trips_through_string() {
613        let root = PathBuf::from("/project");
614        let results = AnalysisResults::default();
615        let elapsed = Duration::from_millis(0);
616        let output = build_json(&results, &root, elapsed).expect("should serialize");
617
618        let json_str = serde_json::to_string(&output).expect("should stringify");
619        let reparsed: serde_json::Value =
620            serde_json::from_str(&json_str).expect("should parse back");
621        assert_eq!(reparsed["total_issues"], 0);
622    }
623
624    // ── Path stripping ──────────────────────────────────────────────
625
626    #[test]
627    fn json_paths_are_relative_to_root() {
628        let root = PathBuf::from("/project");
629        let mut results = AnalysisResults::default();
630        results.unused_files.push(UnusedFile {
631            path: root.join("src/deep/nested/file.ts"),
632        });
633        let elapsed = Duration::from_millis(0);
634        let output = build_json(&results, &root, elapsed).expect("should serialize");
635
636        let path = output["unused_files"][0]["path"].as_str().unwrap();
637        assert_eq!(path, "src/deep/nested/file.ts");
638        assert!(!path.starts_with("/project"));
639    }
640
641    #[test]
642    fn json_strips_root_from_nested_locations() {
643        let root = PathBuf::from("/project");
644        let mut results = AnalysisResults::default();
645        results.unlisted_dependencies.push(UnlistedDependency {
646            package_name: "chalk".to_string(),
647            imported_from: vec![ImportSite {
648                path: root.join("src/cli.ts"),
649                line: 2,
650                col: 0,
651            }],
652        });
653        let elapsed = Duration::from_millis(0);
654        let output = build_json(&results, &root, elapsed).expect("should serialize");
655
656        let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
657            .as_str()
658            .unwrap();
659        assert_eq!(site_path, "src/cli.ts");
660    }
661
662    #[test]
663    fn json_strips_root_from_duplicate_export_locations() {
664        let root = PathBuf::from("/project");
665        let mut results = AnalysisResults::default();
666        results.duplicate_exports.push(DuplicateExport {
667            export_name: "Config".to_string(),
668            locations: vec![
669                DuplicateLocation {
670                    path: root.join("src/config.ts"),
671                    line: 15,
672                    col: 0,
673                },
674                DuplicateLocation {
675                    path: root.join("src/types.ts"),
676                    line: 30,
677                    col: 0,
678                },
679            ],
680        });
681        let elapsed = Duration::from_millis(0);
682        let output = build_json(&results, &root, elapsed).expect("should serialize");
683
684        let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
685            .as_str()
686            .unwrap();
687        let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
688            .as_str()
689            .unwrap();
690        assert_eq!(loc0, "src/config.ts");
691        assert_eq!(loc1, "src/types.ts");
692    }
693
694    #[test]
695    fn json_strips_root_from_circular_dependency_files() {
696        let root = PathBuf::from("/project");
697        let mut results = AnalysisResults::default();
698        results.circular_dependencies.push(CircularDependency {
699            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
700            length: 2,
701            line: 1,
702            col: 0,
703        });
704        let elapsed = Duration::from_millis(0);
705        let output = build_json(&results, &root, elapsed).expect("should serialize");
706
707        let files = output["circular_dependencies"][0]["files"]
708            .as_array()
709            .unwrap();
710        assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
711        assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
712    }
713
714    #[test]
715    fn json_path_outside_root_not_stripped() {
716        let root = PathBuf::from("/project");
717        let mut results = AnalysisResults::default();
718        results.unused_files.push(UnusedFile {
719            path: PathBuf::from("/other/project/src/file.ts"),
720        });
721        let elapsed = Duration::from_millis(0);
722        let output = build_json(&results, &root, elapsed).expect("should serialize");
723
724        let path = output["unused_files"][0]["path"].as_str().unwrap();
725        assert!(path.contains("/other/project/"));
726    }
727
728    // ── Individual issue type field verification ────────────────────
729
730    #[test]
731    fn json_unused_file_contains_path() {
732        let root = PathBuf::from("/project");
733        let mut results = AnalysisResults::default();
734        results.unused_files.push(UnusedFile {
735            path: root.join("src/orphan.ts"),
736        });
737        let elapsed = Duration::from_millis(0);
738        let output = build_json(&results, &root, elapsed).expect("should serialize");
739
740        let file = &output["unused_files"][0];
741        assert_eq!(file["path"], "src/orphan.ts");
742    }
743
744    #[test]
745    fn json_unused_type_contains_expected_fields() {
746        let root = PathBuf::from("/project");
747        let mut results = AnalysisResults::default();
748        results.unused_types.push(UnusedExport {
749            path: root.join("src/types.ts"),
750            export_name: "OldInterface".to_string(),
751            is_type_only: true,
752            line: 20,
753            col: 0,
754            span_start: 300,
755            is_re_export: false,
756        });
757        let elapsed = Duration::from_millis(0);
758        let output = build_json(&results, &root, elapsed).expect("should serialize");
759
760        let typ = &output["unused_types"][0];
761        assert_eq!(typ["export_name"], "OldInterface");
762        assert_eq!(typ["is_type_only"], true);
763        assert_eq!(typ["line"], 20);
764        assert_eq!(typ["path"], "src/types.ts");
765    }
766
767    #[test]
768    fn json_unused_dependency_contains_expected_fields() {
769        let root = PathBuf::from("/project");
770        let mut results = AnalysisResults::default();
771        results.unused_dependencies.push(UnusedDependency {
772            package_name: "axios".to_string(),
773            location: DependencyLocation::Dependencies,
774            path: root.join("package.json"),
775            line: 10,
776        });
777        let elapsed = Duration::from_millis(0);
778        let output = build_json(&results, &root, elapsed).expect("should serialize");
779
780        let dep = &output["unused_dependencies"][0];
781        assert_eq!(dep["package_name"], "axios");
782        assert_eq!(dep["line"], 10);
783    }
784
785    #[test]
786    fn json_unused_dev_dependency_contains_expected_fields() {
787        let root = PathBuf::from("/project");
788        let mut results = AnalysisResults::default();
789        results.unused_dev_dependencies.push(UnusedDependency {
790            package_name: "vitest".to_string(),
791            location: DependencyLocation::DevDependencies,
792            path: root.join("package.json"),
793            line: 15,
794        });
795        let elapsed = Duration::from_millis(0);
796        let output = build_json(&results, &root, elapsed).expect("should serialize");
797
798        let dep = &output["unused_dev_dependencies"][0];
799        assert_eq!(dep["package_name"], "vitest");
800    }
801
802    #[test]
803    fn json_unused_optional_dependency_contains_expected_fields() {
804        let root = PathBuf::from("/project");
805        let mut results = AnalysisResults::default();
806        results.unused_optional_dependencies.push(UnusedDependency {
807            package_name: "fsevents".to_string(),
808            location: DependencyLocation::OptionalDependencies,
809            path: root.join("package.json"),
810            line: 12,
811        });
812        let elapsed = Duration::from_millis(0);
813        let output = build_json(&results, &root, elapsed).expect("should serialize");
814
815        let dep = &output["unused_optional_dependencies"][0];
816        assert_eq!(dep["package_name"], "fsevents");
817        assert_eq!(output["total_issues"], 1);
818    }
819
820    #[test]
821    fn json_unused_enum_member_contains_expected_fields() {
822        let root = PathBuf::from("/project");
823        let mut results = AnalysisResults::default();
824        results.unused_enum_members.push(UnusedMember {
825            path: root.join("src/enums.ts"),
826            parent_name: "Color".to_string(),
827            member_name: "Purple".to_string(),
828            kind: MemberKind::EnumMember,
829            line: 5,
830            col: 2,
831        });
832        let elapsed = Duration::from_millis(0);
833        let output = build_json(&results, &root, elapsed).expect("should serialize");
834
835        let member = &output["unused_enum_members"][0];
836        assert_eq!(member["parent_name"], "Color");
837        assert_eq!(member["member_name"], "Purple");
838        assert_eq!(member["line"], 5);
839        assert_eq!(member["path"], "src/enums.ts");
840    }
841
842    #[test]
843    fn json_unused_class_member_contains_expected_fields() {
844        let root = PathBuf::from("/project");
845        let mut results = AnalysisResults::default();
846        results.unused_class_members.push(UnusedMember {
847            path: root.join("src/api.ts"),
848            parent_name: "ApiClient".to_string(),
849            member_name: "deprecatedFetch".to_string(),
850            kind: MemberKind::ClassMethod,
851            line: 100,
852            col: 4,
853        });
854        let elapsed = Duration::from_millis(0);
855        let output = build_json(&results, &root, elapsed).expect("should serialize");
856
857        let member = &output["unused_class_members"][0];
858        assert_eq!(member["parent_name"], "ApiClient");
859        assert_eq!(member["member_name"], "deprecatedFetch");
860        assert_eq!(member["line"], 100);
861    }
862
863    #[test]
864    fn json_unresolved_import_contains_expected_fields() {
865        let root = PathBuf::from("/project");
866        let mut results = AnalysisResults::default();
867        results.unresolved_imports.push(UnresolvedImport {
868            path: root.join("src/app.ts"),
869            specifier: "@acme/missing-pkg".to_string(),
870            line: 7,
871            col: 0,
872            specifier_col: 0,
873        });
874        let elapsed = Duration::from_millis(0);
875        let output = build_json(&results, &root, elapsed).expect("should serialize");
876
877        let import = &output["unresolved_imports"][0];
878        assert_eq!(import["specifier"], "@acme/missing-pkg");
879        assert_eq!(import["line"], 7);
880        assert_eq!(import["path"], "src/app.ts");
881    }
882
883    #[test]
884    fn json_unlisted_dependency_contains_import_sites() {
885        let root = PathBuf::from("/project");
886        let mut results = AnalysisResults::default();
887        results.unlisted_dependencies.push(UnlistedDependency {
888            package_name: "dotenv".to_string(),
889            imported_from: vec![
890                ImportSite {
891                    path: root.join("src/config.ts"),
892                    line: 1,
893                    col: 0,
894                },
895                ImportSite {
896                    path: root.join("src/server.ts"),
897                    line: 3,
898                    col: 0,
899                },
900            ],
901        });
902        let elapsed = Duration::from_millis(0);
903        let output = build_json(&results, &root, elapsed).expect("should serialize");
904
905        let dep = &output["unlisted_dependencies"][0];
906        assert_eq!(dep["package_name"], "dotenv");
907        let sites = dep["imported_from"].as_array().unwrap();
908        assert_eq!(sites.len(), 2);
909        assert_eq!(sites[0]["path"], "src/config.ts");
910        assert_eq!(sites[1]["path"], "src/server.ts");
911    }
912
913    #[test]
914    fn json_duplicate_export_contains_locations() {
915        let root = PathBuf::from("/project");
916        let mut results = AnalysisResults::default();
917        results.duplicate_exports.push(DuplicateExport {
918            export_name: "Button".to_string(),
919            locations: vec![
920                DuplicateLocation {
921                    path: root.join("src/ui.ts"),
922                    line: 10,
923                    col: 0,
924                },
925                DuplicateLocation {
926                    path: root.join("src/components.ts"),
927                    line: 25,
928                    col: 0,
929                },
930            ],
931        });
932        let elapsed = Duration::from_millis(0);
933        let output = build_json(&results, &root, elapsed).expect("should serialize");
934
935        let dup = &output["duplicate_exports"][0];
936        assert_eq!(dup["export_name"], "Button");
937        let locs = dup["locations"].as_array().unwrap();
938        assert_eq!(locs.len(), 2);
939        assert_eq!(locs[0]["line"], 10);
940        assert_eq!(locs[1]["line"], 25);
941    }
942
943    #[test]
944    fn json_type_only_dependency_contains_expected_fields() {
945        let root = PathBuf::from("/project");
946        let mut results = AnalysisResults::default();
947        results.type_only_dependencies.push(TypeOnlyDependency {
948            package_name: "zod".to_string(),
949            path: root.join("package.json"),
950            line: 8,
951        });
952        let elapsed = Duration::from_millis(0);
953        let output = build_json(&results, &root, elapsed).expect("should serialize");
954
955        let dep = &output["type_only_dependencies"][0];
956        assert_eq!(dep["package_name"], "zod");
957        assert_eq!(dep["line"], 8);
958    }
959
960    #[test]
961    fn json_circular_dependency_contains_expected_fields() {
962        let root = PathBuf::from("/project");
963        let mut results = AnalysisResults::default();
964        results.circular_dependencies.push(CircularDependency {
965            files: vec![
966                root.join("src/a.ts"),
967                root.join("src/b.ts"),
968                root.join("src/c.ts"),
969            ],
970            length: 3,
971            line: 5,
972            col: 0,
973        });
974        let elapsed = Duration::from_millis(0);
975        let output = build_json(&results, &root, elapsed).expect("should serialize");
976
977        let cycle = &output["circular_dependencies"][0];
978        assert_eq!(cycle["length"], 3);
979        assert_eq!(cycle["line"], 5);
980        let files = cycle["files"].as_array().unwrap();
981        assert_eq!(files.len(), 3);
982    }
983
984    // ── Re-export tagging ───────────────────────────────────────────
985
986    #[test]
987    fn json_re_export_flagged_correctly() {
988        let root = PathBuf::from("/project");
989        let mut results = AnalysisResults::default();
990        results.unused_exports.push(UnusedExport {
991            path: root.join("src/index.ts"),
992            export_name: "reExported".to_string(),
993            is_type_only: false,
994            line: 1,
995            col: 0,
996            span_start: 0,
997            is_re_export: true,
998        });
999        let elapsed = Duration::from_millis(0);
1000        let output = build_json(&results, &root, elapsed).expect("should serialize");
1001
1002        assert_eq!(output["unused_exports"][0]["is_re_export"], true);
1003    }
1004
1005    // ── Schema version stability ────────────────────────────────────
1006
1007    #[test]
1008    fn json_schema_version_is_3() {
1009        let root = PathBuf::from("/project");
1010        let results = AnalysisResults::default();
1011        let elapsed = Duration::from_millis(0);
1012        let output = build_json(&results, &root, elapsed).expect("should serialize");
1013
1014        assert_eq!(output["schema_version"], SCHEMA_VERSION);
1015        assert_eq!(output["schema_version"], 3);
1016    }
1017
1018    // ── Version string ──────────────────────────────────────────────
1019
1020    #[test]
1021    fn json_version_matches_cargo_pkg_version() {
1022        let root = PathBuf::from("/project");
1023        let results = AnalysisResults::default();
1024        let elapsed = Duration::from_millis(0);
1025        let output = build_json(&results, &root, elapsed).expect("should serialize");
1026
1027        assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
1028    }
1029
1030    // ── Elapsed time encoding ───────────────────────────────────────
1031
1032    #[test]
1033    fn json_elapsed_ms_zero_duration() {
1034        let root = PathBuf::from("/project");
1035        let results = AnalysisResults::default();
1036        let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
1037
1038        assert_eq!(output["elapsed_ms"], 0);
1039    }
1040
1041    #[test]
1042    fn json_elapsed_ms_large_duration() {
1043        let root = PathBuf::from("/project");
1044        let results = AnalysisResults::default();
1045        let elapsed = Duration::from_secs(120);
1046        let output = build_json(&results, &root, elapsed).expect("should serialize");
1047
1048        assert_eq!(output["elapsed_ms"], 120_000);
1049    }
1050
1051    #[test]
1052    fn json_elapsed_ms_sub_millisecond_truncated() {
1053        let root = PathBuf::from("/project");
1054        let results = AnalysisResults::default();
1055        // 500 microseconds = 0 milliseconds (truncated)
1056        let elapsed = Duration::from_micros(500);
1057        let output = build_json(&results, &root, elapsed).expect("should serialize");
1058
1059        assert_eq!(output["elapsed_ms"], 0);
1060    }
1061
1062    // ── Multiple issues of same type ────────────────────────────────
1063
1064    #[test]
1065    fn json_multiple_unused_files() {
1066        let root = PathBuf::from("/project");
1067        let mut results = AnalysisResults::default();
1068        results.unused_files.push(UnusedFile {
1069            path: root.join("src/a.ts"),
1070        });
1071        results.unused_files.push(UnusedFile {
1072            path: root.join("src/b.ts"),
1073        });
1074        results.unused_files.push(UnusedFile {
1075            path: root.join("src/c.ts"),
1076        });
1077        let elapsed = Duration::from_millis(0);
1078        let output = build_json(&results, &root, elapsed).expect("should serialize");
1079
1080        assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
1081        assert_eq!(output["total_issues"], 3);
1082    }
1083
1084    // ── strip_root_prefix unit tests ────────────────────────────────
1085
1086    #[test]
1087    fn strip_root_prefix_on_string_value() {
1088        let mut value = serde_json::json!("/project/src/file.ts");
1089        strip_root_prefix(&mut value, "/project/");
1090        assert_eq!(value, "src/file.ts");
1091    }
1092
1093    #[test]
1094    fn strip_root_prefix_leaves_non_matching_string() {
1095        let mut value = serde_json::json!("/other/src/file.ts");
1096        strip_root_prefix(&mut value, "/project/");
1097        assert_eq!(value, "/other/src/file.ts");
1098    }
1099
1100    #[test]
1101    fn strip_root_prefix_recurses_into_arrays() {
1102        let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
1103        strip_root_prefix(&mut value, "/project/");
1104        assert_eq!(value[0], "a.ts");
1105        assert_eq!(value[1], "b.ts");
1106        assert_eq!(value[2], "/other/c.ts");
1107    }
1108
1109    #[test]
1110    fn strip_root_prefix_recurses_into_nested_objects() {
1111        let mut value = serde_json::json!({
1112            "outer": {
1113                "path": "/project/src/nested.ts"
1114            }
1115        });
1116        strip_root_prefix(&mut value, "/project/");
1117        assert_eq!(value["outer"]["path"], "src/nested.ts");
1118    }
1119
1120    #[test]
1121    fn strip_root_prefix_leaves_numbers_and_booleans() {
1122        let mut value = serde_json::json!({
1123            "line": 42,
1124            "is_type_only": false,
1125            "path": "/project/src/file.ts"
1126        });
1127        strip_root_prefix(&mut value, "/project/");
1128        assert_eq!(value["line"], 42);
1129        assert_eq!(value["is_type_only"], false);
1130        assert_eq!(value["path"], "src/file.ts");
1131    }
1132
1133    #[test]
1134    fn strip_root_prefix_handles_empty_string_after_strip() {
1135        // Edge case: the string IS the prefix (without trailing content).
1136        // This shouldn't happen in practice but should not panic.
1137        let mut value = serde_json::json!("/project/");
1138        strip_root_prefix(&mut value, "/project/");
1139        assert_eq!(value, "");
1140    }
1141
1142    #[test]
1143    fn strip_root_prefix_deeply_nested_array_of_objects() {
1144        let mut value = serde_json::json!({
1145            "groups": [{
1146                "instances": [{
1147                    "file": "/project/src/a.ts"
1148                }, {
1149                    "file": "/project/src/b.ts"
1150                }]
1151            }]
1152        });
1153        strip_root_prefix(&mut value, "/project/");
1154        assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
1155        assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
1156    }
1157
1158    // ── Full sample results round-trip ──────────────────────────────
1159
1160    #[test]
1161    fn json_full_sample_results_total_issues_correct() {
1162        let root = PathBuf::from("/project");
1163        let results = sample_results(&root);
1164        let elapsed = Duration::from_millis(100);
1165        let output = build_json(&results, &root, elapsed).expect("should serialize");
1166
1167        // sample_results adds one of each issue type (12 total).
1168        // unused_files + unused_exports + unused_types + unused_dependencies
1169        // + unused_dev_dependencies + unused_enum_members + unused_class_members
1170        // + unresolved_imports + unlisted_dependencies + duplicate_exports
1171        // + type_only_dependencies + circular_dependencies
1172        assert_eq!(output["total_issues"], results.total_issues());
1173    }
1174
1175    #[test]
1176    fn json_full_sample_no_absolute_paths_in_output() {
1177        let root = PathBuf::from("/project");
1178        let results = sample_results(&root);
1179        let elapsed = Duration::from_millis(0);
1180        let output = build_json(&results, &root, elapsed).expect("should serialize");
1181
1182        let json_str = serde_json::to_string(&output).expect("should stringify");
1183        // The root prefix should be stripped from all paths.
1184        assert!(!json_str.contains("/project/src/"));
1185        assert!(!json_str.contains("/project/package.json"));
1186    }
1187
1188    // ── JSON output is deterministic ────────────────────────────────
1189
1190    #[test]
1191    fn json_output_is_deterministic() {
1192        let root = PathBuf::from("/project");
1193        let results = sample_results(&root);
1194        let elapsed = Duration::from_millis(50);
1195
1196        let output1 = build_json(&results, &root, elapsed).expect("first build");
1197        let output2 = build_json(&results, &root, elapsed).expect("second build");
1198
1199        assert_eq!(output1, output2);
1200    }
1201
1202    // ── Metadata not overwritten by results fields ──────────────────
1203
1204    #[test]
1205    fn json_results_fields_do_not_shadow_metadata() {
1206        // Ensure that serialized results don't contain keys like "schema_version"
1207        // that could overwrite the metadata fields we insert first.
1208        let root = PathBuf::from("/project");
1209        let results = AnalysisResults::default();
1210        let elapsed = Duration::from_millis(99);
1211        let output = build_json(&results, &root, elapsed).expect("should serialize");
1212
1213        // Metadata should reflect our explicit values, not anything from AnalysisResults.
1214        assert_eq!(output["schema_version"], 3);
1215        assert_eq!(output["elapsed_ms"], 99);
1216    }
1217
1218    // ── All 14 issue type arrays present ────────────────────────────
1219
1220    #[test]
1221    fn json_all_issue_type_arrays_present_in_empty_results() {
1222        let root = PathBuf::from("/project");
1223        let results = AnalysisResults::default();
1224        let elapsed = Duration::from_millis(0);
1225        let output = build_json(&results, &root, elapsed).expect("should serialize");
1226
1227        let expected_arrays = [
1228            "unused_files",
1229            "unused_exports",
1230            "unused_types",
1231            "unused_dependencies",
1232            "unused_dev_dependencies",
1233            "unused_optional_dependencies",
1234            "unused_enum_members",
1235            "unused_class_members",
1236            "unresolved_imports",
1237            "unlisted_dependencies",
1238            "duplicate_exports",
1239            "type_only_dependencies",
1240            "test_only_dependencies",
1241            "circular_dependencies",
1242        ];
1243        for key in &expected_arrays {
1244            assert!(
1245                output[key].is_array(),
1246                "expected '{key}' to be an array in JSON output"
1247            );
1248        }
1249    }
1250
1251    // ── insert_meta ─────────────────────────────────────────────────
1252
1253    #[test]
1254    fn insert_meta_adds_key_to_object() {
1255        let mut output = serde_json::json!({ "foo": 1 });
1256        let meta = serde_json::json!({ "docs": "https://example.com" });
1257        insert_meta(&mut output, meta.clone());
1258        assert_eq!(output["_meta"], meta);
1259    }
1260
1261    #[test]
1262    fn insert_meta_noop_on_non_object() {
1263        let mut output = serde_json::json!([1, 2, 3]);
1264        let meta = serde_json::json!({ "docs": "https://example.com" });
1265        insert_meta(&mut output, meta);
1266        // Should not panic or add anything
1267        assert!(output.is_array());
1268    }
1269
1270    #[test]
1271    fn insert_meta_overwrites_existing_meta() {
1272        let mut output = serde_json::json!({ "_meta": "old" });
1273        let meta = serde_json::json!({ "new": true });
1274        insert_meta(&mut output, meta.clone());
1275        assert_eq!(output["_meta"], meta);
1276    }
1277
1278    // ── build_json_envelope ─────────────────────────────────────────
1279
1280    #[test]
1281    fn build_json_envelope_has_metadata_fields() {
1282        let report = serde_json::json!({ "findings": [] });
1283        let elapsed = Duration::from_millis(42);
1284        let output = build_json_envelope(report, elapsed);
1285
1286        assert_eq!(output["schema_version"], 3);
1287        assert!(output["version"].is_string());
1288        assert_eq!(output["elapsed_ms"], 42);
1289        assert!(output["findings"].is_array());
1290    }
1291
1292    #[test]
1293    fn build_json_envelope_metadata_appears_first() {
1294        let report = serde_json::json!({ "data": "value" });
1295        let output = build_json_envelope(report, Duration::from_millis(10));
1296
1297        let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
1298        assert_eq!(keys[0], "schema_version");
1299        assert_eq!(keys[1], "version");
1300        assert_eq!(keys[2], "elapsed_ms");
1301    }
1302
1303    #[test]
1304    fn build_json_envelope_non_object_report() {
1305        // If report_value is not an Object, only metadata fields appear
1306        let report = serde_json::json!("not an object");
1307        let output = build_json_envelope(report, Duration::from_millis(0));
1308
1309        let obj = output.as_object().unwrap();
1310        assert_eq!(obj.len(), 3);
1311        assert!(obj.contains_key("schema_version"));
1312        assert!(obj.contains_key("version"));
1313        assert!(obj.contains_key("elapsed_ms"));
1314    }
1315
1316    // ── strip_root_prefix with null value ──
1317
1318    #[test]
1319    fn strip_root_prefix_null_unchanged() {
1320        let mut value = serde_json::Value::Null;
1321        strip_root_prefix(&mut value, "/project/");
1322        assert!(value.is_null());
1323    }
1324
1325    // ── strip_root_prefix with empty string ──
1326
1327    #[test]
1328    fn strip_root_prefix_empty_string() {
1329        let mut value = serde_json::json!("");
1330        strip_root_prefix(&mut value, "/project/");
1331        assert_eq!(value, "");
1332    }
1333
1334    // ── strip_root_prefix on mixed nested structure ──
1335
1336    #[test]
1337    fn strip_root_prefix_mixed_types() {
1338        let mut value = serde_json::json!({
1339            "path": "/project/src/file.ts",
1340            "line": 42,
1341            "flag": true,
1342            "nested": {
1343                "items": ["/project/a.ts", 99, null, "/project/b.ts"],
1344                "deep": { "path": "/project/c.ts" }
1345            }
1346        });
1347        strip_root_prefix(&mut value, "/project/");
1348        assert_eq!(value["path"], "src/file.ts");
1349        assert_eq!(value["line"], 42);
1350        assert_eq!(value["flag"], true);
1351        assert_eq!(value["nested"]["items"][0], "a.ts");
1352        assert_eq!(value["nested"]["items"][1], 99);
1353        assert!(value["nested"]["items"][2].is_null());
1354        assert_eq!(value["nested"]["items"][3], "b.ts");
1355        assert_eq!(value["nested"]["deep"]["path"], "c.ts");
1356    }
1357
1358    // ── JSON with explain meta for check ──
1359
1360    #[test]
1361    fn json_check_meta_integrates_correctly() {
1362        let root = PathBuf::from("/project");
1363        let results = AnalysisResults::default();
1364        let elapsed = Duration::from_millis(0);
1365        let mut output = build_json(&results, &root, elapsed).expect("should serialize");
1366        insert_meta(&mut output, crate::explain::check_meta());
1367
1368        assert!(output["_meta"]["docs"].is_string());
1369        assert!(output["_meta"]["rules"].is_object());
1370    }
1371
1372    // ── JSON unused member kind serialization ──
1373
1374    #[test]
1375    fn json_unused_member_kind_serialized() {
1376        let root = PathBuf::from("/project");
1377        let mut results = AnalysisResults::default();
1378        results.unused_enum_members.push(UnusedMember {
1379            path: root.join("src/enums.ts"),
1380            parent_name: "Color".to_string(),
1381            member_name: "Red".to_string(),
1382            kind: MemberKind::EnumMember,
1383            line: 3,
1384            col: 2,
1385        });
1386        results.unused_class_members.push(UnusedMember {
1387            path: root.join("src/class.ts"),
1388            parent_name: "Foo".to_string(),
1389            member_name: "bar".to_string(),
1390            kind: MemberKind::ClassMethod,
1391            line: 10,
1392            col: 4,
1393        });
1394
1395        let elapsed = Duration::from_millis(0);
1396        let output = build_json(&results, &root, elapsed).expect("should serialize");
1397
1398        let enum_member = &output["unused_enum_members"][0];
1399        assert!(enum_member["kind"].is_string());
1400        let class_member = &output["unused_class_members"][0];
1401        assert!(class_member["kind"].is_string());
1402    }
1403
1404    // ── Actions injection ──────────────────────────────────────────
1405
1406    #[test]
1407    fn json_unused_export_has_actions() {
1408        let root = PathBuf::from("/project");
1409        let mut results = AnalysisResults::default();
1410        results.unused_exports.push(UnusedExport {
1411            path: root.join("src/utils.ts"),
1412            export_name: "helperFn".to_string(),
1413            is_type_only: false,
1414            line: 10,
1415            col: 4,
1416            span_start: 120,
1417            is_re_export: false,
1418        });
1419        let output = build_json(&results, &root, Duration::ZERO).unwrap();
1420
1421        let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
1422        assert_eq!(actions.len(), 2);
1423
1424        // Fix action
1425        assert_eq!(actions[0]["type"], "remove-export");
1426        assert_eq!(actions[0]["auto_fixable"], true);
1427        assert!(actions[0].get("note").is_none());
1428
1429        // Suppress action
1430        assert_eq!(actions[1]["type"], "suppress-line");
1431        assert_eq!(
1432            actions[1]["comment"],
1433            "// fallow-ignore-next-line unused-export"
1434        );
1435    }
1436
1437    #[test]
1438    fn json_unused_file_has_file_suppress_and_note() {
1439        let root = PathBuf::from("/project");
1440        let mut results = AnalysisResults::default();
1441        results.unused_files.push(UnusedFile {
1442            path: root.join("src/dead.ts"),
1443        });
1444        let output = build_json(&results, &root, Duration::ZERO).unwrap();
1445
1446        let actions = output["unused_files"][0]["actions"].as_array().unwrap();
1447        assert_eq!(actions[0]["type"], "delete-file");
1448        assert_eq!(actions[0]["auto_fixable"], false);
1449        assert!(actions[0]["note"].is_string());
1450        assert_eq!(actions[1]["type"], "suppress-file");
1451        assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
1452    }
1453
1454    #[test]
1455    fn json_unused_dependency_has_config_suppress_with_package_name() {
1456        let root = PathBuf::from("/project");
1457        let mut results = AnalysisResults::default();
1458        results.unused_dependencies.push(UnusedDependency {
1459            package_name: "lodash".to_string(),
1460            location: DependencyLocation::Dependencies,
1461            path: root.join("package.json"),
1462            line: 5,
1463        });
1464        let output = build_json(&results, &root, Duration::ZERO).unwrap();
1465
1466        let actions = output["unused_dependencies"][0]["actions"]
1467            .as_array()
1468            .unwrap();
1469        assert_eq!(actions[0]["type"], "remove-dependency");
1470        assert_eq!(actions[0]["auto_fixable"], true);
1471
1472        // Config suppress includes actual package name
1473        assert_eq!(actions[1]["type"], "add-to-config");
1474        assert_eq!(actions[1]["config_key"], "ignoreDependencies");
1475        assert_eq!(actions[1]["value"], "lodash");
1476    }
1477
1478    #[test]
1479    fn json_empty_results_have_no_actions_in_empty_arrays() {
1480        let root = PathBuf::from("/project");
1481        let results = AnalysisResults::default();
1482        let output = build_json(&results, &root, Duration::ZERO).unwrap();
1483
1484        // Empty arrays should remain empty
1485        assert!(output["unused_exports"].as_array().unwrap().is_empty());
1486        assert!(output["unused_files"].as_array().unwrap().is_empty());
1487    }
1488
1489    #[test]
1490    fn json_all_issue_types_have_actions() {
1491        let root = PathBuf::from("/project");
1492        let results = sample_results(&root);
1493        let output = build_json(&results, &root, Duration::ZERO).unwrap();
1494
1495        let issue_keys = [
1496            "unused_files",
1497            "unused_exports",
1498            "unused_types",
1499            "unused_dependencies",
1500            "unused_dev_dependencies",
1501            "unused_optional_dependencies",
1502            "unused_enum_members",
1503            "unused_class_members",
1504            "unresolved_imports",
1505            "unlisted_dependencies",
1506            "duplicate_exports",
1507            "type_only_dependencies",
1508            "test_only_dependencies",
1509            "circular_dependencies",
1510        ];
1511
1512        for key in &issue_keys {
1513            let arr = output[key].as_array().unwrap();
1514            if !arr.is_empty() {
1515                let actions = arr[0]["actions"].as_array();
1516                assert!(
1517                    actions.is_some() && !actions.unwrap().is_empty(),
1518                    "missing actions for {key}"
1519                );
1520            }
1521        }
1522    }
1523}