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
37const SCHEMA_VERSION: u32 = 3;
43
44fn 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
71pub 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(&mut output, &root_prefix);
116 inject_actions(&mut output);
117 Ok(output)
118}
119
120fn 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
145enum SuppressKind {
149 InlineComment,
151 FileComment,
153 ConfigIgnoreDep,
155}
156
157struct 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
167fn 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 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
305fn 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 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 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 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 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 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
377fn 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#[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 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 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 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
448fn 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
473fn 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
498fn 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 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#[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 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 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
566fn 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 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
614fn 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
646fn 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 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 #[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 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 assert!(!json_str.contains("/project/src/"));
1431 assert!(!json_str.contains("/project/package.json"));
1432 }
1433
1434 #[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 #[test]
1451 fn json_results_fields_do_not_shadow_metadata() {
1452 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 assert_eq!(output["schema_version"], 3);
1461 assert_eq!(output["elapsed_ms"], 99);
1462 }
1463
1464 #[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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 #[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 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 #[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 assert_eq!(actions[1]["type"], "apply-suggestion");
1947 assert!(
1948 actions[1]["description"]
1949 .as_str()
1950 .unwrap()
1951 .contains("validation logic")
1952 );
1953 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}