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// ── Health action injection ─────────────────────────────────────
403
404/// Inject `actions` arrays into complexity findings in a health JSON output.
405///
406/// Walks `findings` and `targets` arrays, appending machine-actionable
407/// fix and suppress hints to each item.
408#[allow(
409    clippy::redundant_pub_crate,
410    reason = "pub(crate) needed — used by audit.rs via re-export, but not part of public API"
411)]
412pub(crate) fn inject_health_actions(output: &mut serde_json::Value) {
413    let Some(map) = output.as_object_mut() else {
414        return;
415    };
416
417    // Complexity findings: refactor the function to reduce complexity
418    if let Some(findings) = map.get_mut("findings").and_then(|v| v.as_array_mut()) {
419        for item in findings {
420            let actions = build_health_finding_actions(item);
421            if let serde_json::Value::Object(obj) = item {
422                obj.insert("actions".to_string(), actions);
423            }
424        }
425    }
426
427    // Refactoring targets: apply the recommended refactoring
428    if let Some(targets) = map.get_mut("targets").and_then(|v| v.as_array_mut()) {
429        for item in targets {
430            let actions = build_refactoring_target_actions(item);
431            if let serde_json::Value::Object(obj) = item {
432                obj.insert("actions".to_string(), actions);
433            }
434        }
435    }
436
437    // Hotspots: files that are both complex and frequently changing
438    if let Some(hotspots) = map.get_mut("hotspots").and_then(|v| v.as_array_mut()) {
439        for item in hotspots {
440            let actions = build_hotspot_actions(item);
441            if let serde_json::Value::Object(obj) = item {
442                obj.insert("actions".to_string(), actions);
443            }
444        }
445    }
446}
447
448/// Build the `actions` array for a single complexity finding.
449fn build_health_finding_actions(item: &serde_json::Value) -> serde_json::Value {
450    let name = item
451        .get("name")
452        .and_then(serde_json::Value::as_str)
453        .unwrap_or("function");
454
455    let mut actions = vec![serde_json::json!({
456        "type": "refactor-function",
457        "auto_fixable": false,
458        "description": format!("Refactor `{name}` to reduce complexity (extract helper functions, simplify branching)"),
459        "note": "Consider splitting into smaller functions with single responsibilities",
460    })];
461
462    actions.push(serde_json::json!({
463        "type": "suppress-line",
464        "auto_fixable": false,
465        "description": "Suppress with an inline comment above the function declaration",
466        "comment": "// fallow-ignore-next-line complexity",
467        "placement": "above-function-declaration",
468    }));
469
470    serde_json::Value::Array(actions)
471}
472
473/// Build the `actions` array for a single hotspot entry.
474fn build_hotspot_actions(item: &serde_json::Value) -> serde_json::Value {
475    let path = item
476        .get("path")
477        .and_then(serde_json::Value::as_str)
478        .unwrap_or("file");
479
480    let actions = vec![
481        serde_json::json!({
482            "type": "refactor-file",
483            "auto_fixable": false,
484            "description": format!("Refactor `{path}` — high complexity combined with frequent changes makes this a maintenance risk"),
485            "note": "Prioritize extracting complex functions, adding tests, or splitting the module",
486        }),
487        serde_json::json!({
488            "type": "add-tests",
489            "auto_fixable": false,
490            "description": format!("Add test coverage for `{path}` to reduce change risk"),
491            "note": "Frequently changed complex files benefit most from comprehensive test coverage",
492        }),
493    ];
494
495    serde_json::Value::Array(actions)
496}
497
498/// Build the `actions` array for a single refactoring target.
499fn build_refactoring_target_actions(item: &serde_json::Value) -> serde_json::Value {
500    let recommendation = item
501        .get("recommendation")
502        .and_then(serde_json::Value::as_str)
503        .unwrap_or("Apply the recommended refactoring");
504
505    let category = item
506        .get("category")
507        .and_then(serde_json::Value::as_str)
508        .unwrap_or("refactoring");
509
510    let mut actions = vec![serde_json::json!({
511        "type": "apply-refactoring",
512        "auto_fixable": false,
513        "description": recommendation,
514        "category": category,
515    })];
516
517    // Targets with evidence linking to specific functions get a suppress action
518    if item.get("evidence").is_some() {
519        actions.push(serde_json::json!({
520            "type": "suppress-line",
521            "auto_fixable": false,
522            "description": "Suppress the underlying complexity finding",
523            "comment": "// fallow-ignore-next-line complexity",
524        }));
525    }
526
527    serde_json::Value::Array(actions)
528}
529
530// ── Duplication action injection ────────────────────────────────
531
532/// Inject `actions` arrays into clone families/groups in a duplication JSON output.
533///
534/// Walks `clone_families` and `clone_groups` arrays, appending
535/// machine-actionable fix and config hints to each item.
536#[allow(
537    clippy::redundant_pub_crate,
538    reason = "pub(crate) needed — used by audit.rs via re-export, but not part of public API"
539)]
540pub(crate) fn inject_dupes_actions(output: &mut serde_json::Value) {
541    let Some(map) = output.as_object_mut() else {
542        return;
543    };
544
545    // Clone families: extract shared module/function
546    if let Some(families) = map.get_mut("clone_families").and_then(|v| v.as_array_mut()) {
547        for item in families {
548            let actions = build_clone_family_actions(item);
549            if let serde_json::Value::Object(obj) = item {
550                obj.insert("actions".to_string(), actions);
551            }
552        }
553    }
554
555    // Clone groups: extract shared code
556    if let Some(groups) = map.get_mut("clone_groups").and_then(|v| v.as_array_mut()) {
557        for item in groups {
558            let actions = build_clone_group_actions(item);
559            if let serde_json::Value::Object(obj) = item {
560                obj.insert("actions".to_string(), actions);
561            }
562        }
563    }
564}
565
566/// Build the `actions` array for a single clone family.
567fn build_clone_family_actions(item: &serde_json::Value) -> serde_json::Value {
568    let group_count = item
569        .get("groups")
570        .and_then(|v| v.as_array())
571        .map_or(0, Vec::len);
572
573    let total_lines = item
574        .get("total_duplicated_lines")
575        .and_then(serde_json::Value::as_u64)
576        .unwrap_or(0);
577
578    let mut actions = vec![serde_json::json!({
579        "type": "extract-shared",
580        "auto_fixable": false,
581        "description": format!(
582            "Extract {group_count} duplicated code block{} ({total_lines} lines) into a shared module",
583            if group_count == 1 { "" } else { "s" }
584        ),
585        "note": "These clone groups share the same files, indicating a structural relationship — refactor together",
586    })];
587
588    // Include any refactoring suggestions from the family
589    if let Some(suggestions) = item.get("suggestions").and_then(|v| v.as_array()) {
590        for suggestion in suggestions {
591            if let Some(desc) = suggestion
592                .get("description")
593                .and_then(serde_json::Value::as_str)
594            {
595                actions.push(serde_json::json!({
596                    "type": "apply-suggestion",
597                    "auto_fixable": false,
598                    "description": desc,
599                }));
600            }
601        }
602    }
603
604    actions.push(serde_json::json!({
605        "type": "suppress-line",
606        "auto_fixable": false,
607        "description": "Suppress with an inline comment above the duplicated code",
608        "comment": "// fallow-ignore-next-line code-duplication",
609    }));
610
611    serde_json::Value::Array(actions)
612}
613
614/// Build the `actions` array for a single clone group.
615fn build_clone_group_actions(item: &serde_json::Value) -> serde_json::Value {
616    let instance_count = item
617        .get("instances")
618        .and_then(|v| v.as_array())
619        .map_or(0, Vec::len);
620
621    let line_count = item
622        .get("line_count")
623        .and_then(serde_json::Value::as_u64)
624        .unwrap_or(0);
625
626    let actions = vec![
627        serde_json::json!({
628            "type": "extract-shared",
629            "auto_fixable": false,
630            "description": format!(
631                "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
632                if instance_count == 1 { "" } else { "s" }
633            ),
634        }),
635        serde_json::json!({
636            "type": "suppress-line",
637            "auto_fixable": false,
638            "description": "Suppress with an inline comment above the duplicated code",
639            "comment": "// fallow-ignore-next-line code-duplication",
640        }),
641    ];
642
643    serde_json::Value::Array(actions)
644}
645
646/// Insert a `_meta` key into a JSON object value.
647fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
648    if let serde_json::Value::Object(map) = output {
649        map.insert("_meta".to_string(), meta);
650    }
651}
652
653pub(super) fn print_health_json(
654    report: &crate::health_types::HealthReport,
655    root: &Path,
656    elapsed: Duration,
657    explain: bool,
658) -> ExitCode {
659    let report_value = match serde_json::to_value(report) {
660        Ok(v) => v,
661        Err(e) => {
662            eprintln!("Error: failed to serialize health report: {e}");
663            return ExitCode::from(2);
664        }
665    };
666
667    let mut output = build_json_envelope(report_value, elapsed);
668    let root_prefix = format!("{}/", root.display());
669    strip_root_prefix(&mut output, &root_prefix);
670    inject_health_actions(&mut output);
671
672    if explain {
673        insert_meta(&mut output, explain::health_meta());
674    }
675
676    emit_json(&output, "JSON")
677}
678
679pub(super) fn print_duplication_json(
680    report: &DuplicationReport,
681    elapsed: Duration,
682    explain: bool,
683) -> ExitCode {
684    let report_value = match serde_json::to_value(report) {
685        Ok(v) => v,
686        Err(e) => {
687            eprintln!("Error: failed to serialize duplication report: {e}");
688            return ExitCode::from(2);
689        }
690    };
691
692    let mut output = build_json_envelope(report_value, elapsed);
693    inject_dupes_actions(&mut output);
694
695    if explain {
696        insert_meta(&mut output, explain::dupes_meta());
697    }
698
699    emit_json(&output, "JSON")
700}
701
702pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
703    match serde_json::to_string_pretty(value) {
704        Ok(json) => println!("{json}"),
705        Err(e) => {
706            eprintln!("Error: failed to serialize trace output: {e}");
707            #[expect(
708                clippy::exit,
709                reason = "fatal serialization error requires immediate exit"
710            )]
711            std::process::exit(2);
712        }
713    }
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719    use crate::report::test_helpers::sample_results;
720    use fallow_core::extract::MemberKind;
721    use fallow_core::results::*;
722    use std::path::PathBuf;
723    use std::time::Duration;
724
725    #[test]
726    fn json_output_has_metadata_fields() {
727        let root = PathBuf::from("/project");
728        let results = AnalysisResults::default();
729        let elapsed = Duration::from_millis(123);
730        let output = build_json(&results, &root, elapsed).expect("should serialize");
731
732        assert_eq!(output["schema_version"], 3);
733        assert!(output["version"].is_string());
734        assert_eq!(output["elapsed_ms"], 123);
735        assert_eq!(output["total_issues"], 0);
736    }
737
738    #[test]
739    fn json_output_includes_issue_arrays() {
740        let root = PathBuf::from("/project");
741        let results = sample_results(&root);
742        let elapsed = Duration::from_millis(50);
743        let output = build_json(&results, &root, elapsed).expect("should serialize");
744
745        assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
746        assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
747        assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
748        assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
749        assert_eq!(
750            output["unused_dev_dependencies"].as_array().unwrap().len(),
751            1
752        );
753        assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
754        assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
755        assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
756        assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
757        assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
758        assert_eq!(
759            output["type_only_dependencies"].as_array().unwrap().len(),
760            1
761        );
762        assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
763    }
764
765    #[test]
766    fn json_metadata_fields_appear_first() {
767        let root = PathBuf::from("/project");
768        let results = AnalysisResults::default();
769        let elapsed = Duration::from_millis(0);
770        let output = build_json(&results, &root, elapsed).expect("should serialize");
771        let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
772        assert_eq!(keys[0], "schema_version");
773        assert_eq!(keys[1], "version");
774        assert_eq!(keys[2], "elapsed_ms");
775        assert_eq!(keys[3], "total_issues");
776    }
777
778    #[test]
779    fn json_total_issues_matches_results() {
780        let root = PathBuf::from("/project");
781        let results = sample_results(&root);
782        let total = results.total_issues();
783        let elapsed = Duration::from_millis(0);
784        let output = build_json(&results, &root, elapsed).expect("should serialize");
785
786        assert_eq!(output["total_issues"], total);
787    }
788
789    #[test]
790    fn json_unused_export_contains_expected_fields() {
791        let root = PathBuf::from("/project");
792        let mut results = AnalysisResults::default();
793        results.unused_exports.push(UnusedExport {
794            path: root.join("src/utils.ts"),
795            export_name: "helperFn".to_string(),
796            is_type_only: false,
797            line: 10,
798            col: 4,
799            span_start: 120,
800            is_re_export: false,
801        });
802        let elapsed = Duration::from_millis(0);
803        let output = build_json(&results, &root, elapsed).expect("should serialize");
804
805        let export = &output["unused_exports"][0];
806        assert_eq!(export["export_name"], "helperFn");
807        assert_eq!(export["line"], 10);
808        assert_eq!(export["col"], 4);
809        assert_eq!(export["is_type_only"], false);
810        assert_eq!(export["span_start"], 120);
811        assert_eq!(export["is_re_export"], false);
812    }
813
814    #[test]
815    fn json_serializes_to_valid_json() {
816        let root = PathBuf::from("/project");
817        let results = sample_results(&root);
818        let elapsed = Duration::from_millis(42);
819        let output = build_json(&results, &root, elapsed).expect("should serialize");
820
821        let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
822        let reparsed: serde_json::Value =
823            serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
824        assert_eq!(reparsed, output);
825    }
826
827    // ── Empty results ───────────────────────────────────────────────
828
829    #[test]
830    fn json_empty_results_produce_valid_structure() {
831        let root = PathBuf::from("/project");
832        let results = AnalysisResults::default();
833        let elapsed = Duration::from_millis(0);
834        let output = build_json(&results, &root, elapsed).expect("should serialize");
835
836        assert_eq!(output["total_issues"], 0);
837        assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
838        assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
839        assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
840        assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
841        assert_eq!(
842            output["unused_dev_dependencies"].as_array().unwrap().len(),
843            0
844        );
845        assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
846        assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
847        assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
848        assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
849        assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
850        assert_eq!(
851            output["type_only_dependencies"].as_array().unwrap().len(),
852            0
853        );
854        assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
855    }
856
857    #[test]
858    fn json_empty_results_round_trips_through_string() {
859        let root = PathBuf::from("/project");
860        let results = AnalysisResults::default();
861        let elapsed = Duration::from_millis(0);
862        let output = build_json(&results, &root, elapsed).expect("should serialize");
863
864        let json_str = serde_json::to_string(&output).expect("should stringify");
865        let reparsed: serde_json::Value =
866            serde_json::from_str(&json_str).expect("should parse back");
867        assert_eq!(reparsed["total_issues"], 0);
868    }
869
870    // ── Path stripping ──────────────────────────────────────────────
871
872    #[test]
873    fn json_paths_are_relative_to_root() {
874        let root = PathBuf::from("/project");
875        let mut results = AnalysisResults::default();
876        results.unused_files.push(UnusedFile {
877            path: root.join("src/deep/nested/file.ts"),
878        });
879        let elapsed = Duration::from_millis(0);
880        let output = build_json(&results, &root, elapsed).expect("should serialize");
881
882        let path = output["unused_files"][0]["path"].as_str().unwrap();
883        assert_eq!(path, "src/deep/nested/file.ts");
884        assert!(!path.starts_with("/project"));
885    }
886
887    #[test]
888    fn json_strips_root_from_nested_locations() {
889        let root = PathBuf::from("/project");
890        let mut results = AnalysisResults::default();
891        results.unlisted_dependencies.push(UnlistedDependency {
892            package_name: "chalk".to_string(),
893            imported_from: vec![ImportSite {
894                path: root.join("src/cli.ts"),
895                line: 2,
896                col: 0,
897            }],
898        });
899        let elapsed = Duration::from_millis(0);
900        let output = build_json(&results, &root, elapsed).expect("should serialize");
901
902        let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
903            .as_str()
904            .unwrap();
905        assert_eq!(site_path, "src/cli.ts");
906    }
907
908    #[test]
909    fn json_strips_root_from_duplicate_export_locations() {
910        let root = PathBuf::from("/project");
911        let mut results = AnalysisResults::default();
912        results.duplicate_exports.push(DuplicateExport {
913            export_name: "Config".to_string(),
914            locations: vec![
915                DuplicateLocation {
916                    path: root.join("src/config.ts"),
917                    line: 15,
918                    col: 0,
919                },
920                DuplicateLocation {
921                    path: root.join("src/types.ts"),
922                    line: 30,
923                    col: 0,
924                },
925            ],
926        });
927        let elapsed = Duration::from_millis(0);
928        let output = build_json(&results, &root, elapsed).expect("should serialize");
929
930        let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
931            .as_str()
932            .unwrap();
933        let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
934            .as_str()
935            .unwrap();
936        assert_eq!(loc0, "src/config.ts");
937        assert_eq!(loc1, "src/types.ts");
938    }
939
940    #[test]
941    fn json_strips_root_from_circular_dependency_files() {
942        let root = PathBuf::from("/project");
943        let mut results = AnalysisResults::default();
944        results.circular_dependencies.push(CircularDependency {
945            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
946            length: 2,
947            line: 1,
948            col: 0,
949        });
950        let elapsed = Duration::from_millis(0);
951        let output = build_json(&results, &root, elapsed).expect("should serialize");
952
953        let files = output["circular_dependencies"][0]["files"]
954            .as_array()
955            .unwrap();
956        assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
957        assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
958    }
959
960    #[test]
961    fn json_path_outside_root_not_stripped() {
962        let root = PathBuf::from("/project");
963        let mut results = AnalysisResults::default();
964        results.unused_files.push(UnusedFile {
965            path: PathBuf::from("/other/project/src/file.ts"),
966        });
967        let elapsed = Duration::from_millis(0);
968        let output = build_json(&results, &root, elapsed).expect("should serialize");
969
970        let path = output["unused_files"][0]["path"].as_str().unwrap();
971        assert!(path.contains("/other/project/"));
972    }
973
974    // ── Individual issue type field verification ────────────────────
975
976    #[test]
977    fn json_unused_file_contains_path() {
978        let root = PathBuf::from("/project");
979        let mut results = AnalysisResults::default();
980        results.unused_files.push(UnusedFile {
981            path: root.join("src/orphan.ts"),
982        });
983        let elapsed = Duration::from_millis(0);
984        let output = build_json(&results, &root, elapsed).expect("should serialize");
985
986        let file = &output["unused_files"][0];
987        assert_eq!(file["path"], "src/orphan.ts");
988    }
989
990    #[test]
991    fn json_unused_type_contains_expected_fields() {
992        let root = PathBuf::from("/project");
993        let mut results = AnalysisResults::default();
994        results.unused_types.push(UnusedExport {
995            path: root.join("src/types.ts"),
996            export_name: "OldInterface".to_string(),
997            is_type_only: true,
998            line: 20,
999            col: 0,
1000            span_start: 300,
1001            is_re_export: false,
1002        });
1003        let elapsed = Duration::from_millis(0);
1004        let output = build_json(&results, &root, elapsed).expect("should serialize");
1005
1006        let typ = &output["unused_types"][0];
1007        assert_eq!(typ["export_name"], "OldInterface");
1008        assert_eq!(typ["is_type_only"], true);
1009        assert_eq!(typ["line"], 20);
1010        assert_eq!(typ["path"], "src/types.ts");
1011    }
1012
1013    #[test]
1014    fn json_unused_dependency_contains_expected_fields() {
1015        let root = PathBuf::from("/project");
1016        let mut results = AnalysisResults::default();
1017        results.unused_dependencies.push(UnusedDependency {
1018            package_name: "axios".to_string(),
1019            location: DependencyLocation::Dependencies,
1020            path: root.join("package.json"),
1021            line: 10,
1022        });
1023        let elapsed = Duration::from_millis(0);
1024        let output = build_json(&results, &root, elapsed).expect("should serialize");
1025
1026        let dep = &output["unused_dependencies"][0];
1027        assert_eq!(dep["package_name"], "axios");
1028        assert_eq!(dep["line"], 10);
1029    }
1030
1031    #[test]
1032    fn json_unused_dev_dependency_contains_expected_fields() {
1033        let root = PathBuf::from("/project");
1034        let mut results = AnalysisResults::default();
1035        results.unused_dev_dependencies.push(UnusedDependency {
1036            package_name: "vitest".to_string(),
1037            location: DependencyLocation::DevDependencies,
1038            path: root.join("package.json"),
1039            line: 15,
1040        });
1041        let elapsed = Duration::from_millis(0);
1042        let output = build_json(&results, &root, elapsed).expect("should serialize");
1043
1044        let dep = &output["unused_dev_dependencies"][0];
1045        assert_eq!(dep["package_name"], "vitest");
1046    }
1047
1048    #[test]
1049    fn json_unused_optional_dependency_contains_expected_fields() {
1050        let root = PathBuf::from("/project");
1051        let mut results = AnalysisResults::default();
1052        results.unused_optional_dependencies.push(UnusedDependency {
1053            package_name: "fsevents".to_string(),
1054            location: DependencyLocation::OptionalDependencies,
1055            path: root.join("package.json"),
1056            line: 12,
1057        });
1058        let elapsed = Duration::from_millis(0);
1059        let output = build_json(&results, &root, elapsed).expect("should serialize");
1060
1061        let dep = &output["unused_optional_dependencies"][0];
1062        assert_eq!(dep["package_name"], "fsevents");
1063        assert_eq!(output["total_issues"], 1);
1064    }
1065
1066    #[test]
1067    fn json_unused_enum_member_contains_expected_fields() {
1068        let root = PathBuf::from("/project");
1069        let mut results = AnalysisResults::default();
1070        results.unused_enum_members.push(UnusedMember {
1071            path: root.join("src/enums.ts"),
1072            parent_name: "Color".to_string(),
1073            member_name: "Purple".to_string(),
1074            kind: MemberKind::EnumMember,
1075            line: 5,
1076            col: 2,
1077        });
1078        let elapsed = Duration::from_millis(0);
1079        let output = build_json(&results, &root, elapsed).expect("should serialize");
1080
1081        let member = &output["unused_enum_members"][0];
1082        assert_eq!(member["parent_name"], "Color");
1083        assert_eq!(member["member_name"], "Purple");
1084        assert_eq!(member["line"], 5);
1085        assert_eq!(member["path"], "src/enums.ts");
1086    }
1087
1088    #[test]
1089    fn json_unused_class_member_contains_expected_fields() {
1090        let root = PathBuf::from("/project");
1091        let mut results = AnalysisResults::default();
1092        results.unused_class_members.push(UnusedMember {
1093            path: root.join("src/api.ts"),
1094            parent_name: "ApiClient".to_string(),
1095            member_name: "deprecatedFetch".to_string(),
1096            kind: MemberKind::ClassMethod,
1097            line: 100,
1098            col: 4,
1099        });
1100        let elapsed = Duration::from_millis(0);
1101        let output = build_json(&results, &root, elapsed).expect("should serialize");
1102
1103        let member = &output["unused_class_members"][0];
1104        assert_eq!(member["parent_name"], "ApiClient");
1105        assert_eq!(member["member_name"], "deprecatedFetch");
1106        assert_eq!(member["line"], 100);
1107    }
1108
1109    #[test]
1110    fn json_unresolved_import_contains_expected_fields() {
1111        let root = PathBuf::from("/project");
1112        let mut results = AnalysisResults::default();
1113        results.unresolved_imports.push(UnresolvedImport {
1114            path: root.join("src/app.ts"),
1115            specifier: "@acme/missing-pkg".to_string(),
1116            line: 7,
1117            col: 0,
1118            specifier_col: 0,
1119        });
1120        let elapsed = Duration::from_millis(0);
1121        let output = build_json(&results, &root, elapsed).expect("should serialize");
1122
1123        let import = &output["unresolved_imports"][0];
1124        assert_eq!(import["specifier"], "@acme/missing-pkg");
1125        assert_eq!(import["line"], 7);
1126        assert_eq!(import["path"], "src/app.ts");
1127    }
1128
1129    #[test]
1130    fn json_unlisted_dependency_contains_import_sites() {
1131        let root = PathBuf::from("/project");
1132        let mut results = AnalysisResults::default();
1133        results.unlisted_dependencies.push(UnlistedDependency {
1134            package_name: "dotenv".to_string(),
1135            imported_from: vec![
1136                ImportSite {
1137                    path: root.join("src/config.ts"),
1138                    line: 1,
1139                    col: 0,
1140                },
1141                ImportSite {
1142                    path: root.join("src/server.ts"),
1143                    line: 3,
1144                    col: 0,
1145                },
1146            ],
1147        });
1148        let elapsed = Duration::from_millis(0);
1149        let output = build_json(&results, &root, elapsed).expect("should serialize");
1150
1151        let dep = &output["unlisted_dependencies"][0];
1152        assert_eq!(dep["package_name"], "dotenv");
1153        let sites = dep["imported_from"].as_array().unwrap();
1154        assert_eq!(sites.len(), 2);
1155        assert_eq!(sites[0]["path"], "src/config.ts");
1156        assert_eq!(sites[1]["path"], "src/server.ts");
1157    }
1158
1159    #[test]
1160    fn json_duplicate_export_contains_locations() {
1161        let root = PathBuf::from("/project");
1162        let mut results = AnalysisResults::default();
1163        results.duplicate_exports.push(DuplicateExport {
1164            export_name: "Button".to_string(),
1165            locations: vec![
1166                DuplicateLocation {
1167                    path: root.join("src/ui.ts"),
1168                    line: 10,
1169                    col: 0,
1170                },
1171                DuplicateLocation {
1172                    path: root.join("src/components.ts"),
1173                    line: 25,
1174                    col: 0,
1175                },
1176            ],
1177        });
1178        let elapsed = Duration::from_millis(0);
1179        let output = build_json(&results, &root, elapsed).expect("should serialize");
1180
1181        let dup = &output["duplicate_exports"][0];
1182        assert_eq!(dup["export_name"], "Button");
1183        let locs = dup["locations"].as_array().unwrap();
1184        assert_eq!(locs.len(), 2);
1185        assert_eq!(locs[0]["line"], 10);
1186        assert_eq!(locs[1]["line"], 25);
1187    }
1188
1189    #[test]
1190    fn json_type_only_dependency_contains_expected_fields() {
1191        let root = PathBuf::from("/project");
1192        let mut results = AnalysisResults::default();
1193        results.type_only_dependencies.push(TypeOnlyDependency {
1194            package_name: "zod".to_string(),
1195            path: root.join("package.json"),
1196            line: 8,
1197        });
1198        let elapsed = Duration::from_millis(0);
1199        let output = build_json(&results, &root, elapsed).expect("should serialize");
1200
1201        let dep = &output["type_only_dependencies"][0];
1202        assert_eq!(dep["package_name"], "zod");
1203        assert_eq!(dep["line"], 8);
1204    }
1205
1206    #[test]
1207    fn json_circular_dependency_contains_expected_fields() {
1208        let root = PathBuf::from("/project");
1209        let mut results = AnalysisResults::default();
1210        results.circular_dependencies.push(CircularDependency {
1211            files: vec![
1212                root.join("src/a.ts"),
1213                root.join("src/b.ts"),
1214                root.join("src/c.ts"),
1215            ],
1216            length: 3,
1217            line: 5,
1218            col: 0,
1219        });
1220        let elapsed = Duration::from_millis(0);
1221        let output = build_json(&results, &root, elapsed).expect("should serialize");
1222
1223        let cycle = &output["circular_dependencies"][0];
1224        assert_eq!(cycle["length"], 3);
1225        assert_eq!(cycle["line"], 5);
1226        let files = cycle["files"].as_array().unwrap();
1227        assert_eq!(files.len(), 3);
1228    }
1229
1230    // ── Re-export tagging ───────────────────────────────────────────
1231
1232    #[test]
1233    fn json_re_export_flagged_correctly() {
1234        let root = PathBuf::from("/project");
1235        let mut results = AnalysisResults::default();
1236        results.unused_exports.push(UnusedExport {
1237            path: root.join("src/index.ts"),
1238            export_name: "reExported".to_string(),
1239            is_type_only: false,
1240            line: 1,
1241            col: 0,
1242            span_start: 0,
1243            is_re_export: true,
1244        });
1245        let elapsed = Duration::from_millis(0);
1246        let output = build_json(&results, &root, elapsed).expect("should serialize");
1247
1248        assert_eq!(output["unused_exports"][0]["is_re_export"], true);
1249    }
1250
1251    // ── Schema version stability ────────────────────────────────────
1252
1253    #[test]
1254    fn json_schema_version_is_3() {
1255        let root = PathBuf::from("/project");
1256        let results = AnalysisResults::default();
1257        let elapsed = Duration::from_millis(0);
1258        let output = build_json(&results, &root, elapsed).expect("should serialize");
1259
1260        assert_eq!(output["schema_version"], SCHEMA_VERSION);
1261        assert_eq!(output["schema_version"], 3);
1262    }
1263
1264    // ── Version string ──────────────────────────────────────────────
1265
1266    #[test]
1267    fn json_version_matches_cargo_pkg_version() {
1268        let root = PathBuf::from("/project");
1269        let results = AnalysisResults::default();
1270        let elapsed = Duration::from_millis(0);
1271        let output = build_json(&results, &root, elapsed).expect("should serialize");
1272
1273        assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
1274    }
1275
1276    // ── Elapsed time encoding ───────────────────────────────────────
1277
1278    #[test]
1279    fn json_elapsed_ms_zero_duration() {
1280        let root = PathBuf::from("/project");
1281        let results = AnalysisResults::default();
1282        let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
1283
1284        assert_eq!(output["elapsed_ms"], 0);
1285    }
1286
1287    #[test]
1288    fn json_elapsed_ms_large_duration() {
1289        let root = PathBuf::from("/project");
1290        let results = AnalysisResults::default();
1291        let elapsed = Duration::from_secs(120);
1292        let output = build_json(&results, &root, elapsed).expect("should serialize");
1293
1294        assert_eq!(output["elapsed_ms"], 120_000);
1295    }
1296
1297    #[test]
1298    fn json_elapsed_ms_sub_millisecond_truncated() {
1299        let root = PathBuf::from("/project");
1300        let results = AnalysisResults::default();
1301        // 500 microseconds = 0 milliseconds (truncated)
1302        let elapsed = Duration::from_micros(500);
1303        let output = build_json(&results, &root, elapsed).expect("should serialize");
1304
1305        assert_eq!(output["elapsed_ms"], 0);
1306    }
1307
1308    // ── Multiple issues of same type ────────────────────────────────
1309
1310    #[test]
1311    fn json_multiple_unused_files() {
1312        let root = PathBuf::from("/project");
1313        let mut results = AnalysisResults::default();
1314        results.unused_files.push(UnusedFile {
1315            path: root.join("src/a.ts"),
1316        });
1317        results.unused_files.push(UnusedFile {
1318            path: root.join("src/b.ts"),
1319        });
1320        results.unused_files.push(UnusedFile {
1321            path: root.join("src/c.ts"),
1322        });
1323        let elapsed = Duration::from_millis(0);
1324        let output = build_json(&results, &root, elapsed).expect("should serialize");
1325
1326        assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
1327        assert_eq!(output["total_issues"], 3);
1328    }
1329
1330    // ── strip_root_prefix unit tests ────────────────────────────────
1331
1332    #[test]
1333    fn strip_root_prefix_on_string_value() {
1334        let mut value = serde_json::json!("/project/src/file.ts");
1335        strip_root_prefix(&mut value, "/project/");
1336        assert_eq!(value, "src/file.ts");
1337    }
1338
1339    #[test]
1340    fn strip_root_prefix_leaves_non_matching_string() {
1341        let mut value = serde_json::json!("/other/src/file.ts");
1342        strip_root_prefix(&mut value, "/project/");
1343        assert_eq!(value, "/other/src/file.ts");
1344    }
1345
1346    #[test]
1347    fn strip_root_prefix_recurses_into_arrays() {
1348        let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
1349        strip_root_prefix(&mut value, "/project/");
1350        assert_eq!(value[0], "a.ts");
1351        assert_eq!(value[1], "b.ts");
1352        assert_eq!(value[2], "/other/c.ts");
1353    }
1354
1355    #[test]
1356    fn strip_root_prefix_recurses_into_nested_objects() {
1357        let mut value = serde_json::json!({
1358            "outer": {
1359                "path": "/project/src/nested.ts"
1360            }
1361        });
1362        strip_root_prefix(&mut value, "/project/");
1363        assert_eq!(value["outer"]["path"], "src/nested.ts");
1364    }
1365
1366    #[test]
1367    fn strip_root_prefix_leaves_numbers_and_booleans() {
1368        let mut value = serde_json::json!({
1369            "line": 42,
1370            "is_type_only": false,
1371            "path": "/project/src/file.ts"
1372        });
1373        strip_root_prefix(&mut value, "/project/");
1374        assert_eq!(value["line"], 42);
1375        assert_eq!(value["is_type_only"], false);
1376        assert_eq!(value["path"], "src/file.ts");
1377    }
1378
1379    #[test]
1380    fn strip_root_prefix_handles_empty_string_after_strip() {
1381        // Edge case: the string IS the prefix (without trailing content).
1382        // This shouldn't happen in practice but should not panic.
1383        let mut value = serde_json::json!("/project/");
1384        strip_root_prefix(&mut value, "/project/");
1385        assert_eq!(value, "");
1386    }
1387
1388    #[test]
1389    fn strip_root_prefix_deeply_nested_array_of_objects() {
1390        let mut value = serde_json::json!({
1391            "groups": [{
1392                "instances": [{
1393                    "file": "/project/src/a.ts"
1394                }, {
1395                    "file": "/project/src/b.ts"
1396                }]
1397            }]
1398        });
1399        strip_root_prefix(&mut value, "/project/");
1400        assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
1401        assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
1402    }
1403
1404    // ── Full sample results round-trip ──────────────────────────────
1405
1406    #[test]
1407    fn json_full_sample_results_total_issues_correct() {
1408        let root = PathBuf::from("/project");
1409        let results = sample_results(&root);
1410        let elapsed = Duration::from_millis(100);
1411        let output = build_json(&results, &root, elapsed).expect("should serialize");
1412
1413        // sample_results adds one of each issue type (12 total).
1414        // unused_files + unused_exports + unused_types + unused_dependencies
1415        // + unused_dev_dependencies + unused_enum_members + unused_class_members
1416        // + unresolved_imports + unlisted_dependencies + duplicate_exports
1417        // + type_only_dependencies + circular_dependencies
1418        assert_eq!(output["total_issues"], results.total_issues());
1419    }
1420
1421    #[test]
1422    fn json_full_sample_no_absolute_paths_in_output() {
1423        let root = PathBuf::from("/project");
1424        let results = sample_results(&root);
1425        let elapsed = Duration::from_millis(0);
1426        let output = build_json(&results, &root, elapsed).expect("should serialize");
1427
1428        let json_str = serde_json::to_string(&output).expect("should stringify");
1429        // The root prefix should be stripped from all paths.
1430        assert!(!json_str.contains("/project/src/"));
1431        assert!(!json_str.contains("/project/package.json"));
1432    }
1433
1434    // ── JSON output is deterministic ────────────────────────────────
1435
1436    #[test]
1437    fn json_output_is_deterministic() {
1438        let root = PathBuf::from("/project");
1439        let results = sample_results(&root);
1440        let elapsed = Duration::from_millis(50);
1441
1442        let output1 = build_json(&results, &root, elapsed).expect("first build");
1443        let output2 = build_json(&results, &root, elapsed).expect("second build");
1444
1445        assert_eq!(output1, output2);
1446    }
1447
1448    // ── Metadata not overwritten by results fields ──────────────────
1449
1450    #[test]
1451    fn json_results_fields_do_not_shadow_metadata() {
1452        // Ensure that serialized results don't contain keys like "schema_version"
1453        // that could overwrite the metadata fields we insert first.
1454        let root = PathBuf::from("/project");
1455        let results = AnalysisResults::default();
1456        let elapsed = Duration::from_millis(99);
1457        let output = build_json(&results, &root, elapsed).expect("should serialize");
1458
1459        // Metadata should reflect our explicit values, not anything from AnalysisResults.
1460        assert_eq!(output["schema_version"], 3);
1461        assert_eq!(output["elapsed_ms"], 99);
1462    }
1463
1464    // ── All 14 issue type arrays present ────────────────────────────
1465
1466    #[test]
1467    fn json_all_issue_type_arrays_present_in_empty_results() {
1468        let root = PathBuf::from("/project");
1469        let results = AnalysisResults::default();
1470        let elapsed = Duration::from_millis(0);
1471        let output = build_json(&results, &root, elapsed).expect("should serialize");
1472
1473        let expected_arrays = [
1474            "unused_files",
1475            "unused_exports",
1476            "unused_types",
1477            "unused_dependencies",
1478            "unused_dev_dependencies",
1479            "unused_optional_dependencies",
1480            "unused_enum_members",
1481            "unused_class_members",
1482            "unresolved_imports",
1483            "unlisted_dependencies",
1484            "duplicate_exports",
1485            "type_only_dependencies",
1486            "test_only_dependencies",
1487            "circular_dependencies",
1488        ];
1489        for key in &expected_arrays {
1490            assert!(
1491                output[key].is_array(),
1492                "expected '{key}' to be an array in JSON output"
1493            );
1494        }
1495    }
1496
1497    // ── insert_meta ─────────────────────────────────────────────────
1498
1499    #[test]
1500    fn insert_meta_adds_key_to_object() {
1501        let mut output = serde_json::json!({ "foo": 1 });
1502        let meta = serde_json::json!({ "docs": "https://example.com" });
1503        insert_meta(&mut output, meta.clone());
1504        assert_eq!(output["_meta"], meta);
1505    }
1506
1507    #[test]
1508    fn insert_meta_noop_on_non_object() {
1509        let mut output = serde_json::json!([1, 2, 3]);
1510        let meta = serde_json::json!({ "docs": "https://example.com" });
1511        insert_meta(&mut output, meta);
1512        // Should not panic or add anything
1513        assert!(output.is_array());
1514    }
1515
1516    #[test]
1517    fn insert_meta_overwrites_existing_meta() {
1518        let mut output = serde_json::json!({ "_meta": "old" });
1519        let meta = serde_json::json!({ "new": true });
1520        insert_meta(&mut output, meta.clone());
1521        assert_eq!(output["_meta"], meta);
1522    }
1523
1524    // ── build_json_envelope ─────────────────────────────────────────
1525
1526    #[test]
1527    fn build_json_envelope_has_metadata_fields() {
1528        let report = serde_json::json!({ "findings": [] });
1529        let elapsed = Duration::from_millis(42);
1530        let output = build_json_envelope(report, elapsed);
1531
1532        assert_eq!(output["schema_version"], 3);
1533        assert!(output["version"].is_string());
1534        assert_eq!(output["elapsed_ms"], 42);
1535        assert!(output["findings"].is_array());
1536    }
1537
1538    #[test]
1539    fn build_json_envelope_metadata_appears_first() {
1540        let report = serde_json::json!({ "data": "value" });
1541        let output = build_json_envelope(report, Duration::from_millis(10));
1542
1543        let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
1544        assert_eq!(keys[0], "schema_version");
1545        assert_eq!(keys[1], "version");
1546        assert_eq!(keys[2], "elapsed_ms");
1547    }
1548
1549    #[test]
1550    fn build_json_envelope_non_object_report() {
1551        // If report_value is not an Object, only metadata fields appear
1552        let report = serde_json::json!("not an object");
1553        let output = build_json_envelope(report, Duration::from_millis(0));
1554
1555        let obj = output.as_object().unwrap();
1556        assert_eq!(obj.len(), 3);
1557        assert!(obj.contains_key("schema_version"));
1558        assert!(obj.contains_key("version"));
1559        assert!(obj.contains_key("elapsed_ms"));
1560    }
1561
1562    // ── strip_root_prefix with null value ──
1563
1564    #[test]
1565    fn strip_root_prefix_null_unchanged() {
1566        let mut value = serde_json::Value::Null;
1567        strip_root_prefix(&mut value, "/project/");
1568        assert!(value.is_null());
1569    }
1570
1571    // ── strip_root_prefix with empty string ──
1572
1573    #[test]
1574    fn strip_root_prefix_empty_string() {
1575        let mut value = serde_json::json!("");
1576        strip_root_prefix(&mut value, "/project/");
1577        assert_eq!(value, "");
1578    }
1579
1580    // ── strip_root_prefix on mixed nested structure ──
1581
1582    #[test]
1583    fn strip_root_prefix_mixed_types() {
1584        let mut value = serde_json::json!({
1585            "path": "/project/src/file.ts",
1586            "line": 42,
1587            "flag": true,
1588            "nested": {
1589                "items": ["/project/a.ts", 99, null, "/project/b.ts"],
1590                "deep": { "path": "/project/c.ts" }
1591            }
1592        });
1593        strip_root_prefix(&mut value, "/project/");
1594        assert_eq!(value["path"], "src/file.ts");
1595        assert_eq!(value["line"], 42);
1596        assert_eq!(value["flag"], true);
1597        assert_eq!(value["nested"]["items"][0], "a.ts");
1598        assert_eq!(value["nested"]["items"][1], 99);
1599        assert!(value["nested"]["items"][2].is_null());
1600        assert_eq!(value["nested"]["items"][3], "b.ts");
1601        assert_eq!(value["nested"]["deep"]["path"], "c.ts");
1602    }
1603
1604    // ── JSON with explain meta for check ──
1605
1606    #[test]
1607    fn json_check_meta_integrates_correctly() {
1608        let root = PathBuf::from("/project");
1609        let results = AnalysisResults::default();
1610        let elapsed = Duration::from_millis(0);
1611        let mut output = build_json(&results, &root, elapsed).expect("should serialize");
1612        insert_meta(&mut output, crate::explain::check_meta());
1613
1614        assert!(output["_meta"]["docs"].is_string());
1615        assert!(output["_meta"]["rules"].is_object());
1616    }
1617
1618    // ── JSON unused member kind serialization ──
1619
1620    #[test]
1621    fn json_unused_member_kind_serialized() {
1622        let root = PathBuf::from("/project");
1623        let mut results = AnalysisResults::default();
1624        results.unused_enum_members.push(UnusedMember {
1625            path: root.join("src/enums.ts"),
1626            parent_name: "Color".to_string(),
1627            member_name: "Red".to_string(),
1628            kind: MemberKind::EnumMember,
1629            line: 3,
1630            col: 2,
1631        });
1632        results.unused_class_members.push(UnusedMember {
1633            path: root.join("src/class.ts"),
1634            parent_name: "Foo".to_string(),
1635            member_name: "bar".to_string(),
1636            kind: MemberKind::ClassMethod,
1637            line: 10,
1638            col: 4,
1639        });
1640
1641        let elapsed = Duration::from_millis(0);
1642        let output = build_json(&results, &root, elapsed).expect("should serialize");
1643
1644        let enum_member = &output["unused_enum_members"][0];
1645        assert!(enum_member["kind"].is_string());
1646        let class_member = &output["unused_class_members"][0];
1647        assert!(class_member["kind"].is_string());
1648    }
1649
1650    // ── Actions injection ──────────────────────────────────────────
1651
1652    #[test]
1653    fn json_unused_export_has_actions() {
1654        let root = PathBuf::from("/project");
1655        let mut results = AnalysisResults::default();
1656        results.unused_exports.push(UnusedExport {
1657            path: root.join("src/utils.ts"),
1658            export_name: "helperFn".to_string(),
1659            is_type_only: false,
1660            line: 10,
1661            col: 4,
1662            span_start: 120,
1663            is_re_export: false,
1664        });
1665        let output = build_json(&results, &root, Duration::ZERO).unwrap();
1666
1667        let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
1668        assert_eq!(actions.len(), 2);
1669
1670        // Fix action
1671        assert_eq!(actions[0]["type"], "remove-export");
1672        assert_eq!(actions[0]["auto_fixable"], true);
1673        assert!(actions[0].get("note").is_none());
1674
1675        // Suppress action
1676        assert_eq!(actions[1]["type"], "suppress-line");
1677        assert_eq!(
1678            actions[1]["comment"],
1679            "// fallow-ignore-next-line unused-export"
1680        );
1681    }
1682
1683    #[test]
1684    fn json_unused_file_has_file_suppress_and_note() {
1685        let root = PathBuf::from("/project");
1686        let mut results = AnalysisResults::default();
1687        results.unused_files.push(UnusedFile {
1688            path: root.join("src/dead.ts"),
1689        });
1690        let output = build_json(&results, &root, Duration::ZERO).unwrap();
1691
1692        let actions = output["unused_files"][0]["actions"].as_array().unwrap();
1693        assert_eq!(actions[0]["type"], "delete-file");
1694        assert_eq!(actions[0]["auto_fixable"], false);
1695        assert!(actions[0]["note"].is_string());
1696        assert_eq!(actions[1]["type"], "suppress-file");
1697        assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
1698    }
1699
1700    #[test]
1701    fn json_unused_dependency_has_config_suppress_with_package_name() {
1702        let root = PathBuf::from("/project");
1703        let mut results = AnalysisResults::default();
1704        results.unused_dependencies.push(UnusedDependency {
1705            package_name: "lodash".to_string(),
1706            location: DependencyLocation::Dependencies,
1707            path: root.join("package.json"),
1708            line: 5,
1709        });
1710        let output = build_json(&results, &root, Duration::ZERO).unwrap();
1711
1712        let actions = output["unused_dependencies"][0]["actions"]
1713            .as_array()
1714            .unwrap();
1715        assert_eq!(actions[0]["type"], "remove-dependency");
1716        assert_eq!(actions[0]["auto_fixable"], true);
1717
1718        // Config suppress includes actual package name
1719        assert_eq!(actions[1]["type"], "add-to-config");
1720        assert_eq!(actions[1]["config_key"], "ignoreDependencies");
1721        assert_eq!(actions[1]["value"], "lodash");
1722    }
1723
1724    #[test]
1725    fn json_empty_results_have_no_actions_in_empty_arrays() {
1726        let root = PathBuf::from("/project");
1727        let results = AnalysisResults::default();
1728        let output = build_json(&results, &root, Duration::ZERO).unwrap();
1729
1730        // Empty arrays should remain empty
1731        assert!(output["unused_exports"].as_array().unwrap().is_empty());
1732        assert!(output["unused_files"].as_array().unwrap().is_empty());
1733    }
1734
1735    #[test]
1736    fn json_all_issue_types_have_actions() {
1737        let root = PathBuf::from("/project");
1738        let results = sample_results(&root);
1739        let output = build_json(&results, &root, Duration::ZERO).unwrap();
1740
1741        let issue_keys = [
1742            "unused_files",
1743            "unused_exports",
1744            "unused_types",
1745            "unused_dependencies",
1746            "unused_dev_dependencies",
1747            "unused_optional_dependencies",
1748            "unused_enum_members",
1749            "unused_class_members",
1750            "unresolved_imports",
1751            "unlisted_dependencies",
1752            "duplicate_exports",
1753            "type_only_dependencies",
1754            "test_only_dependencies",
1755            "circular_dependencies",
1756        ];
1757
1758        for key in &issue_keys {
1759            let arr = output[key].as_array().unwrap();
1760            if !arr.is_empty() {
1761                let actions = arr[0]["actions"].as_array();
1762                assert!(
1763                    actions.is_some() && !actions.unwrap().is_empty(),
1764                    "missing actions for {key}"
1765                );
1766            }
1767        }
1768    }
1769
1770    // ── Health actions injection ───────────────────────────────────
1771
1772    #[test]
1773    fn health_finding_has_actions() {
1774        let mut output = serde_json::json!({
1775            "findings": [{
1776                "path": "src/utils.ts",
1777                "name": "processData",
1778                "line": 10,
1779                "col": 0,
1780                "cyclomatic": 25,
1781                "cognitive": 30,
1782                "line_count": 150,
1783                "exceeded": "both"
1784            }]
1785        });
1786
1787        inject_health_actions(&mut output);
1788
1789        let actions = output["findings"][0]["actions"].as_array().unwrap();
1790        assert_eq!(actions.len(), 2);
1791        assert_eq!(actions[0]["type"], "refactor-function");
1792        assert_eq!(actions[0]["auto_fixable"], false);
1793        assert!(
1794            actions[0]["description"]
1795                .as_str()
1796                .unwrap()
1797                .contains("processData")
1798        );
1799        assert_eq!(actions[1]["type"], "suppress-line");
1800        assert_eq!(
1801            actions[1]["comment"],
1802            "// fallow-ignore-next-line complexity"
1803        );
1804    }
1805
1806    #[test]
1807    fn refactoring_target_has_actions() {
1808        let mut output = serde_json::json!({
1809            "targets": [{
1810                "path": "src/big-module.ts",
1811                "priority": 85.0,
1812                "efficiency": 42.5,
1813                "recommendation": "Split module: 12 exports, 4 unused",
1814                "category": "split_high_impact",
1815                "effort": "medium",
1816                "confidence": "high",
1817                "evidence": { "unused_exports": 4 }
1818            }]
1819        });
1820
1821        inject_health_actions(&mut output);
1822
1823        let actions = output["targets"][0]["actions"].as_array().unwrap();
1824        assert_eq!(actions.len(), 2);
1825        assert_eq!(actions[0]["type"], "apply-refactoring");
1826        assert_eq!(
1827            actions[0]["description"],
1828            "Split module: 12 exports, 4 unused"
1829        );
1830        assert_eq!(actions[0]["category"], "split_high_impact");
1831        // Target with evidence gets suppress action
1832        assert_eq!(actions[1]["type"], "suppress-line");
1833    }
1834
1835    #[test]
1836    fn refactoring_target_without_evidence_has_no_suppress() {
1837        let mut output = serde_json::json!({
1838            "targets": [{
1839                "path": "src/simple.ts",
1840                "priority": 30.0,
1841                "efficiency": 15.0,
1842                "recommendation": "Consider extracting helper functions",
1843                "category": "extract_complex_functions",
1844                "effort": "small",
1845                "confidence": "medium"
1846            }]
1847        });
1848
1849        inject_health_actions(&mut output);
1850
1851        let actions = output["targets"][0]["actions"].as_array().unwrap();
1852        assert_eq!(actions.len(), 1);
1853        assert_eq!(actions[0]["type"], "apply-refactoring");
1854    }
1855
1856    #[test]
1857    fn health_empty_findings_no_actions() {
1858        let mut output = serde_json::json!({
1859            "findings": [],
1860            "targets": []
1861        });
1862
1863        inject_health_actions(&mut output);
1864
1865        assert!(output["findings"].as_array().unwrap().is_empty());
1866        assert!(output["targets"].as_array().unwrap().is_empty());
1867    }
1868
1869    #[test]
1870    fn hotspot_has_actions() {
1871        let mut output = serde_json::json!({
1872            "hotspots": [{
1873                "path": "src/utils.ts",
1874                "complexity_score": 45.0,
1875                "churn_score": 12,
1876                "hotspot_score": 540.0
1877            }]
1878        });
1879
1880        inject_health_actions(&mut output);
1881
1882        let actions = output["hotspots"][0]["actions"].as_array().unwrap();
1883        assert_eq!(actions.len(), 2);
1884        assert_eq!(actions[0]["type"], "refactor-file");
1885        assert!(
1886            actions[0]["description"]
1887                .as_str()
1888                .unwrap()
1889                .contains("src/utils.ts")
1890        );
1891        assert_eq!(actions[1]["type"], "add-tests");
1892    }
1893
1894    #[test]
1895    fn health_finding_suppress_has_placement() {
1896        let mut output = serde_json::json!({
1897            "findings": [{
1898                "path": "src/utils.ts",
1899                "name": "processData",
1900                "line": 10,
1901                "col": 0,
1902                "cyclomatic": 25,
1903                "cognitive": 30,
1904                "line_count": 150,
1905                "exceeded": "both"
1906            }]
1907        });
1908
1909        inject_health_actions(&mut output);
1910
1911        let suppress = &output["findings"][0]["actions"][1];
1912        assert_eq!(suppress["placement"], "above-function-declaration");
1913    }
1914
1915    // ── Duplication actions injection ─────────────────────────────
1916
1917    #[test]
1918    fn clone_family_has_actions() {
1919        let mut output = serde_json::json!({
1920            "clone_families": [{
1921                "files": ["src/a.ts", "src/b.ts"],
1922                "groups": [
1923                    { "instances": [{"file": "src/a.ts"}, {"file": "src/b.ts"}], "token_count": 100, "line_count": 20 }
1924                ],
1925                "total_duplicated_lines": 20,
1926                "total_duplicated_tokens": 100,
1927                "suggestions": [
1928                    { "kind": "ExtractFunction", "description": "Extract shared validation logic", "estimated_savings": 15 }
1929                ]
1930            }]
1931        });
1932
1933        inject_dupes_actions(&mut output);
1934
1935        let actions = output["clone_families"][0]["actions"].as_array().unwrap();
1936        assert_eq!(actions.len(), 3);
1937        assert_eq!(actions[0]["type"], "extract-shared");
1938        assert_eq!(actions[0]["auto_fixable"], false);
1939        assert!(
1940            actions[0]["description"]
1941                .as_str()
1942                .unwrap()
1943                .contains("20 lines")
1944        );
1945        // Suggestion forwarded as action
1946        assert_eq!(actions[1]["type"], "apply-suggestion");
1947        assert!(
1948            actions[1]["description"]
1949                .as_str()
1950                .unwrap()
1951                .contains("validation logic")
1952        );
1953        // Suppress action
1954        assert_eq!(actions[2]["type"], "suppress-line");
1955        assert_eq!(
1956            actions[2]["comment"],
1957            "// fallow-ignore-next-line code-duplication"
1958        );
1959    }
1960
1961    #[test]
1962    fn clone_group_has_actions() {
1963        let mut output = serde_json::json!({
1964            "clone_groups": [{
1965                "instances": [
1966                    {"file": "src/a.ts", "start_line": 1, "end_line": 10},
1967                    {"file": "src/b.ts", "start_line": 5, "end_line": 14}
1968                ],
1969                "token_count": 50,
1970                "line_count": 10
1971            }]
1972        });
1973
1974        inject_dupes_actions(&mut output);
1975
1976        let actions = output["clone_groups"][0]["actions"].as_array().unwrap();
1977        assert_eq!(actions.len(), 2);
1978        assert_eq!(actions[0]["type"], "extract-shared");
1979        assert!(
1980            actions[0]["description"]
1981                .as_str()
1982                .unwrap()
1983                .contains("10 lines")
1984        );
1985        assert!(
1986            actions[0]["description"]
1987                .as_str()
1988                .unwrap()
1989                .contains("2 instances")
1990        );
1991        assert_eq!(actions[1]["type"], "suppress-line");
1992    }
1993
1994    #[test]
1995    fn dupes_empty_results_no_actions() {
1996        let mut output = serde_json::json!({
1997            "clone_families": [],
1998            "clone_groups": []
1999        });
2000
2001        inject_dupes_actions(&mut output);
2002
2003        assert!(output["clone_families"].as_array().unwrap().is_empty());
2004        assert!(output["clone_groups"].as_array().unwrap().is_empty());
2005    }
2006}