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