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;
10use crate::report::grouping::{OwnershipResolver, ResultGroup};
11
12pub(super) fn print_json(
13 results: &AnalysisResults,
14 root: &Path,
15 elapsed: Duration,
16 explain: bool,
17 regression: Option<&crate::regression::RegressionOutcome>,
18) -> ExitCode {
19 match build_json(results, root, elapsed) {
20 Ok(mut output) => {
21 if let Some(outcome) = regression
22 && let serde_json::Value::Object(ref mut map) = output
23 {
24 map.insert("regression".to_string(), outcome.to_json());
25 }
26 if explain {
27 insert_meta(&mut output, explain::check_meta());
28 }
29 emit_json(&output, "JSON")
30 }
31 Err(e) => {
32 eprintln!("Error: failed to serialize results: {e}");
33 ExitCode::from(2)
34 }
35 }
36}
37
38#[must_use]
44pub(super) fn print_grouped_json(
45 groups: &[ResultGroup],
46 original: &AnalysisResults,
47 root: &Path,
48 elapsed: Duration,
49 explain: bool,
50 resolver: &OwnershipResolver,
51) -> ExitCode {
52 let root_prefix = format!("{}/", root.display());
53
54 let group_values: Vec<serde_json::Value> = groups
55 .iter()
56 .filter_map(|group| {
57 let mut value = serde_json::to_value(&group.results).ok()?;
58 strip_root_prefix(&mut value, &root_prefix);
59 inject_actions(&mut value);
60
61 if let serde_json::Value::Object(ref mut map) = value {
62 let mut ordered = serde_json::Map::new();
64 ordered.insert("key".to_string(), serde_json::json!(group.key));
65 ordered.insert(
66 "total_issues".to_string(),
67 serde_json::json!(group.results.total_issues()),
68 );
69 for (k, v) in map.iter() {
70 ordered.insert(k.clone(), v.clone());
71 }
72 Some(serde_json::Value::Object(ordered))
73 } else {
74 Some(value)
75 }
76 })
77 .collect();
78
79 let mut output = serde_json::json!({
80 "schema_version": SCHEMA_VERSION,
81 "version": env!("CARGO_PKG_VERSION"),
82 "elapsed_ms": elapsed.as_millis() as u64,
83 "grouped_by": resolver.mode_label(),
84 "total_issues": original.total_issues(),
85 "groups": group_values,
86 });
87
88 if explain {
89 insert_meta(&mut output, explain::check_meta());
90 }
91
92 emit_json(&output, "JSON")
93}
94
95const SCHEMA_VERSION: u32 = 3;
101
102fn build_json_envelope(report_value: serde_json::Value, elapsed: Duration) -> serde_json::Value {
108 let mut map = serde_json::Map::new();
109 map.insert(
110 "schema_version".to_string(),
111 serde_json::json!(SCHEMA_VERSION),
112 );
113 map.insert(
114 "version".to_string(),
115 serde_json::json!(env!("CARGO_PKG_VERSION")),
116 );
117 map.insert(
118 "elapsed_ms".to_string(),
119 serde_json::json!(elapsed.as_millis()),
120 );
121 if let serde_json::Value::Object(report_map) = report_value {
122 for (key, value) in report_map {
123 map.insert(key, value);
124 }
125 }
126 serde_json::Value::Object(map)
127}
128
129pub fn build_json(
138 results: &AnalysisResults,
139 root: &Path,
140 elapsed: Duration,
141) -> Result<serde_json::Value, serde_json::Error> {
142 let results_value = serde_json::to_value(results)?;
143
144 let mut map = serde_json::Map::new();
145 map.insert(
146 "schema_version".to_string(),
147 serde_json::json!(SCHEMA_VERSION),
148 );
149 map.insert(
150 "version".to_string(),
151 serde_json::json!(env!("CARGO_PKG_VERSION")),
152 );
153 map.insert(
154 "elapsed_ms".to_string(),
155 serde_json::json!(elapsed.as_millis()),
156 );
157 map.insert(
158 "total_issues".to_string(),
159 serde_json::json!(results.total_issues()),
160 );
161
162 if let serde_json::Value::Object(results_map) = results_value {
163 for (key, value) in results_map {
164 map.insert(key, value);
165 }
166 }
167
168 let mut output = serde_json::Value::Object(map);
169 let root_prefix = format!("{}/", root.display());
170 strip_root_prefix(&mut output, &root_prefix);
174 inject_actions(&mut output);
175 Ok(output)
176}
177
178fn strip_root_prefix(value: &mut serde_json::Value, prefix: &str) {
183 match value {
184 serde_json::Value::String(s) => {
185 if let Some(rest) = s.strip_prefix(prefix) {
186 *s = rest.to_string();
187 }
188 }
189 serde_json::Value::Array(arr) => {
190 for item in arr {
191 strip_root_prefix(item, prefix);
192 }
193 }
194 serde_json::Value::Object(map) => {
195 for (_, v) in map.iter_mut() {
196 strip_root_prefix(v, prefix);
197 }
198 }
199 _ => {}
200 }
201}
202
203enum SuppressKind {
207 InlineComment,
209 FileComment,
211 ConfigIgnoreDep,
213}
214
215struct ActionSpec {
217 fix_type: &'static str,
218 auto_fixable: bool,
219 description: &'static str,
220 note: Option<&'static str>,
221 suppress: SuppressKind,
222 issue_kind: &'static str,
223}
224
225fn actions_for_issue_type(key: &str) -> Option<ActionSpec> {
227 match key {
228 "unused_files" => Some(ActionSpec {
229 fix_type: "delete-file",
230 auto_fixable: false,
231 description: "Delete this file",
232 note: Some(
233 "File deletion may remove runtime functionality not visible to static analysis",
234 ),
235 suppress: SuppressKind::FileComment,
236 issue_kind: "unused-file",
237 }),
238 "unused_exports" => Some(ActionSpec {
239 fix_type: "remove-export",
240 auto_fixable: true,
241 description: "Remove the `export` keyword from the declaration",
242 note: None,
243 suppress: SuppressKind::InlineComment,
244 issue_kind: "unused-export",
245 }),
246 "unused_types" => Some(ActionSpec {
247 fix_type: "remove-export",
248 auto_fixable: true,
249 description: "Remove the `export` (or `export type`) keyword from the type declaration",
250 note: None,
251 suppress: SuppressKind::InlineComment,
252 issue_kind: "unused-type",
253 }),
254 "unused_dependencies" => Some(ActionSpec {
255 fix_type: "remove-dependency",
256 auto_fixable: true,
257 description: "Remove from dependencies in package.json",
258 note: None,
259 suppress: SuppressKind::ConfigIgnoreDep,
260 issue_kind: "unused-dependency",
261 }),
262 "unused_dev_dependencies" => Some(ActionSpec {
263 fix_type: "remove-dependency",
264 auto_fixable: true,
265 description: "Remove from devDependencies in package.json",
266 note: None,
267 suppress: SuppressKind::ConfigIgnoreDep,
268 issue_kind: "unused-dev-dependency",
269 }),
270 "unused_optional_dependencies" => Some(ActionSpec {
271 fix_type: "remove-dependency",
272 auto_fixable: true,
273 description: "Remove from optionalDependencies in package.json",
274 note: None,
275 suppress: SuppressKind::ConfigIgnoreDep,
276 issue_kind: "unused-dependency",
278 }),
279 "unused_enum_members" => Some(ActionSpec {
280 fix_type: "remove-enum-member",
281 auto_fixable: true,
282 description: "Remove this enum member",
283 note: None,
284 suppress: SuppressKind::InlineComment,
285 issue_kind: "unused-enum-member",
286 }),
287 "unused_class_members" => Some(ActionSpec {
288 fix_type: "remove-class-member",
289 auto_fixable: false,
290 description: "Remove this class member",
291 note: Some("Class member may be used via dependency injection or decorators"),
292 suppress: SuppressKind::InlineComment,
293 issue_kind: "unused-class-member",
294 }),
295 "unresolved_imports" => Some(ActionSpec {
296 fix_type: "resolve-import",
297 auto_fixable: false,
298 description: "Fix the import specifier or install the missing module",
299 note: Some("Verify the module path and check tsconfig paths configuration"),
300 suppress: SuppressKind::InlineComment,
301 issue_kind: "unresolved-import",
302 }),
303 "unlisted_dependencies" => Some(ActionSpec {
304 fix_type: "install-dependency",
305 auto_fixable: false,
306 description: "Add this package to dependencies in package.json",
307 note: Some("Verify this package should be a direct dependency before adding"),
308 suppress: SuppressKind::ConfigIgnoreDep,
309 issue_kind: "unlisted-dependency",
310 }),
311 "duplicate_exports" => Some(ActionSpec {
312 fix_type: "remove-duplicate",
313 auto_fixable: false,
314 description: "Keep one canonical export location and remove the others",
315 note: Some("Review all locations to determine which should be the canonical export"),
316 suppress: SuppressKind::InlineComment,
317 issue_kind: "duplicate-export",
318 }),
319 "type_only_dependencies" => Some(ActionSpec {
320 fix_type: "move-to-dev",
321 auto_fixable: false,
322 description: "Move to devDependencies (only type imports are used)",
323 note: Some(
324 "Type imports are erased at runtime so this dependency is not needed in production",
325 ),
326 suppress: SuppressKind::ConfigIgnoreDep,
327 issue_kind: "type-only-dependency",
328 }),
329 "test_only_dependencies" => Some(ActionSpec {
330 fix_type: "move-to-dev",
331 auto_fixable: false,
332 description: "Move to devDependencies (only test files import this)",
333 note: Some(
334 "Only test files import this package so it does not need to be a production dependency",
335 ),
336 suppress: SuppressKind::ConfigIgnoreDep,
337 issue_kind: "test-only-dependency",
338 }),
339 "circular_dependencies" => Some(ActionSpec {
340 fix_type: "refactor-cycle",
341 auto_fixable: false,
342 description: "Extract shared logic into a separate module to break the cycle",
343 note: Some(
344 "Circular imports can cause initialization issues and make code harder to reason about",
345 ),
346 suppress: SuppressKind::InlineComment,
347 issue_kind: "circular-dependency",
348 }),
349 "boundary_violations" => Some(ActionSpec {
350 fix_type: "refactor-boundary",
351 auto_fixable: false,
352 description: "Move the import through an allowed zone or restructure the dependency",
353 note: Some(
354 "This import crosses an architecture boundary that is not permitted by the configured rules",
355 ),
356 suppress: SuppressKind::InlineComment,
357 issue_kind: "boundary-violation",
358 }),
359 _ => None,
360 }
361}
362
363fn build_actions(
365 item: &serde_json::Value,
366 issue_key: &str,
367 spec: &ActionSpec,
368) -> serde_json::Value {
369 let mut actions = Vec::with_capacity(2);
370
371 let mut fix_action = serde_json::json!({
373 "type": spec.fix_type,
374 "auto_fixable": spec.auto_fixable,
375 "description": spec.description,
376 });
377 if let Some(note) = spec.note {
378 fix_action["note"] = serde_json::json!(note);
379 }
380 if (issue_key == "unused_exports" || issue_key == "unused_types")
382 && item
383 .get("is_re_export")
384 .and_then(serde_json::Value::as_bool)
385 == Some(true)
386 {
387 fix_action["note"] = serde_json::json!(
388 "This finding originates from a re-export; verify it is not part of your public API before removing"
389 );
390 }
391 actions.push(fix_action);
392
393 match spec.suppress {
395 SuppressKind::InlineComment => {
396 let mut suppress = serde_json::json!({
397 "type": "suppress-line",
398 "auto_fixable": false,
399 "description": "Suppress with an inline comment above the line",
400 "comment": format!("// fallow-ignore-next-line {}", spec.issue_kind),
401 });
402 if issue_key == "duplicate_exports" {
404 suppress["scope"] = serde_json::json!("per-location");
405 }
406 actions.push(suppress);
407 }
408 SuppressKind::FileComment => {
409 actions.push(serde_json::json!({
410 "type": "suppress-file",
411 "auto_fixable": false,
412 "description": "Suppress with a file-level comment at the top of the file",
413 "comment": format!("// fallow-ignore-file {}", spec.issue_kind),
414 }));
415 }
416 SuppressKind::ConfigIgnoreDep => {
417 let pkg = item
419 .get("package_name")
420 .and_then(serde_json::Value::as_str)
421 .unwrap_or("package-name");
422 actions.push(serde_json::json!({
423 "type": "add-to-config",
424 "auto_fixable": false,
425 "description": format!("Add \"{pkg}\" to ignoreDependencies in fallow config"),
426 "config_key": "ignoreDependencies",
427 "value": pkg,
428 }));
429 }
430 }
431
432 serde_json::Value::Array(actions)
433}
434
435fn inject_actions(output: &mut serde_json::Value) {
440 let Some(map) = output.as_object_mut() else {
441 return;
442 };
443
444 for (key, value) in map.iter_mut() {
445 let Some(spec) = actions_for_issue_type(key) else {
446 continue;
447 };
448 let Some(arr) = value.as_array_mut() else {
449 continue;
450 };
451 for item in arr {
452 let actions = build_actions(item, key, &spec);
453 if let serde_json::Value::Object(obj) = item {
454 obj.insert("actions".to_string(), actions);
455 }
456 }
457 }
458}
459
460#[allow(
467 clippy::redundant_pub_crate,
468 reason = "pub(crate) needed — used by audit.rs via re-export, but not part of public API"
469)]
470pub(crate) fn inject_health_actions(output: &mut serde_json::Value) {
471 let Some(map) = output.as_object_mut() else {
472 return;
473 };
474
475 if let Some(findings) = map.get_mut("findings").and_then(|v| v.as_array_mut()) {
477 for item in findings {
478 let actions = build_health_finding_actions(item);
479 if let serde_json::Value::Object(obj) = item {
480 obj.insert("actions".to_string(), actions);
481 }
482 }
483 }
484
485 if let Some(targets) = map.get_mut("targets").and_then(|v| v.as_array_mut()) {
487 for item in targets {
488 let actions = build_refactoring_target_actions(item);
489 if let serde_json::Value::Object(obj) = item {
490 obj.insert("actions".to_string(), actions);
491 }
492 }
493 }
494
495 if let Some(hotspots) = map.get_mut("hotspots").and_then(|v| v.as_array_mut()) {
497 for item in hotspots {
498 let actions = build_hotspot_actions(item);
499 if let serde_json::Value::Object(obj) = item {
500 obj.insert("actions".to_string(), actions);
501 }
502 }
503 }
504}
505
506fn build_health_finding_actions(item: &serde_json::Value) -> serde_json::Value {
508 let name = item
509 .get("name")
510 .and_then(serde_json::Value::as_str)
511 .unwrap_or("function");
512
513 let mut actions = vec![serde_json::json!({
514 "type": "refactor-function",
515 "auto_fixable": false,
516 "description": format!("Refactor `{name}` to reduce complexity (extract helper functions, simplify branching)"),
517 "note": "Consider splitting into smaller functions with single responsibilities",
518 })];
519
520 actions.push(serde_json::json!({
521 "type": "suppress-line",
522 "auto_fixable": false,
523 "description": "Suppress with an inline comment above the function declaration",
524 "comment": "// fallow-ignore-next-line complexity",
525 "placement": "above-function-declaration",
526 }));
527
528 serde_json::Value::Array(actions)
529}
530
531fn build_hotspot_actions(item: &serde_json::Value) -> serde_json::Value {
533 let path = item
534 .get("path")
535 .and_then(serde_json::Value::as_str)
536 .unwrap_or("file");
537
538 let actions = vec![
539 serde_json::json!({
540 "type": "refactor-file",
541 "auto_fixable": false,
542 "description": format!("Refactor `{path}` — high complexity combined with frequent changes makes this a maintenance risk"),
543 "note": "Prioritize extracting complex functions, adding tests, or splitting the module",
544 }),
545 serde_json::json!({
546 "type": "add-tests",
547 "auto_fixable": false,
548 "description": format!("Add test coverage for `{path}` to reduce change risk"),
549 "note": "Frequently changed complex files benefit most from comprehensive test coverage",
550 }),
551 ];
552
553 serde_json::Value::Array(actions)
554}
555
556fn build_refactoring_target_actions(item: &serde_json::Value) -> serde_json::Value {
558 let recommendation = item
559 .get("recommendation")
560 .and_then(serde_json::Value::as_str)
561 .unwrap_or("Apply the recommended refactoring");
562
563 let category = item
564 .get("category")
565 .and_then(serde_json::Value::as_str)
566 .unwrap_or("refactoring");
567
568 let mut actions = vec![serde_json::json!({
569 "type": "apply-refactoring",
570 "auto_fixable": false,
571 "description": recommendation,
572 "category": category,
573 })];
574
575 if item.get("evidence").is_some() {
577 actions.push(serde_json::json!({
578 "type": "suppress-line",
579 "auto_fixable": false,
580 "description": "Suppress the underlying complexity finding",
581 "comment": "// fallow-ignore-next-line complexity",
582 }));
583 }
584
585 serde_json::Value::Array(actions)
586}
587
588#[allow(
595 clippy::redundant_pub_crate,
596 reason = "pub(crate) needed — used by audit.rs via re-export, but not part of public API"
597)]
598pub(crate) fn inject_dupes_actions(output: &mut serde_json::Value) {
599 let Some(map) = output.as_object_mut() else {
600 return;
601 };
602
603 if let Some(families) = map.get_mut("clone_families").and_then(|v| v.as_array_mut()) {
605 for item in families {
606 let actions = build_clone_family_actions(item);
607 if let serde_json::Value::Object(obj) = item {
608 obj.insert("actions".to_string(), actions);
609 }
610 }
611 }
612
613 if let Some(groups) = map.get_mut("clone_groups").and_then(|v| v.as_array_mut()) {
615 for item in groups {
616 let actions = build_clone_group_actions(item);
617 if let serde_json::Value::Object(obj) = item {
618 obj.insert("actions".to_string(), actions);
619 }
620 }
621 }
622}
623
624fn build_clone_family_actions(item: &serde_json::Value) -> serde_json::Value {
626 let group_count = item
627 .get("groups")
628 .and_then(|v| v.as_array())
629 .map_or(0, Vec::len);
630
631 let total_lines = item
632 .get("total_duplicated_lines")
633 .and_then(serde_json::Value::as_u64)
634 .unwrap_or(0);
635
636 let mut actions = vec![serde_json::json!({
637 "type": "extract-shared",
638 "auto_fixable": false,
639 "description": format!(
640 "Extract {group_count} duplicated code block{} ({total_lines} lines) into a shared module",
641 if group_count == 1 { "" } else { "s" }
642 ),
643 "note": "These clone groups share the same files, indicating a structural relationship — refactor together",
644 })];
645
646 if let Some(suggestions) = item.get("suggestions").and_then(|v| v.as_array()) {
648 for suggestion in suggestions {
649 if let Some(desc) = suggestion
650 .get("description")
651 .and_then(serde_json::Value::as_str)
652 {
653 actions.push(serde_json::json!({
654 "type": "apply-suggestion",
655 "auto_fixable": false,
656 "description": desc,
657 }));
658 }
659 }
660 }
661
662 actions.push(serde_json::json!({
663 "type": "suppress-line",
664 "auto_fixable": false,
665 "description": "Suppress with an inline comment above the duplicated code",
666 "comment": "// fallow-ignore-next-line code-duplication",
667 }));
668
669 serde_json::Value::Array(actions)
670}
671
672fn build_clone_group_actions(item: &serde_json::Value) -> serde_json::Value {
674 let instance_count = item
675 .get("instances")
676 .and_then(|v| v.as_array())
677 .map_or(0, Vec::len);
678
679 let line_count = item
680 .get("line_count")
681 .and_then(serde_json::Value::as_u64)
682 .unwrap_or(0);
683
684 let actions = vec![
685 serde_json::json!({
686 "type": "extract-shared",
687 "auto_fixable": false,
688 "description": format!(
689 "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
690 if instance_count == 1 { "" } else { "s" }
691 ),
692 }),
693 serde_json::json!({
694 "type": "suppress-line",
695 "auto_fixable": false,
696 "description": "Suppress with an inline comment above the duplicated code",
697 "comment": "// fallow-ignore-next-line code-duplication",
698 }),
699 ];
700
701 serde_json::Value::Array(actions)
702}
703
704fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
706 if let serde_json::Value::Object(map) = output {
707 map.insert("_meta".to_string(), meta);
708 }
709}
710
711pub(super) fn print_health_json(
712 report: &crate::health_types::HealthReport,
713 root: &Path,
714 elapsed: Duration,
715 explain: bool,
716) -> ExitCode {
717 let report_value = match serde_json::to_value(report) {
718 Ok(v) => v,
719 Err(e) => {
720 eprintln!("Error: failed to serialize health report: {e}");
721 return ExitCode::from(2);
722 }
723 };
724
725 let mut output = build_json_envelope(report_value, elapsed);
726 let root_prefix = format!("{}/", root.display());
727 strip_root_prefix(&mut output, &root_prefix);
728 inject_health_actions(&mut output);
729
730 if explain {
731 insert_meta(&mut output, explain::health_meta());
732 }
733
734 emit_json(&output, "JSON")
735}
736
737pub(super) fn print_duplication_json(
738 report: &DuplicationReport,
739 elapsed: Duration,
740 explain: bool,
741) -> ExitCode {
742 let report_value = match serde_json::to_value(report) {
743 Ok(v) => v,
744 Err(e) => {
745 eprintln!("Error: failed to serialize duplication report: {e}");
746 return ExitCode::from(2);
747 }
748 };
749
750 let mut output = build_json_envelope(report_value, elapsed);
751 inject_dupes_actions(&mut output);
752
753 if explain {
754 insert_meta(&mut output, explain::dupes_meta());
755 }
756
757 emit_json(&output, "JSON")
758}
759
760pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
761 match serde_json::to_string_pretty(value) {
762 Ok(json) => println!("{json}"),
763 Err(e) => {
764 eprintln!("Error: failed to serialize trace output: {e}");
765 #[expect(
766 clippy::exit,
767 reason = "fatal serialization error requires immediate exit"
768 )]
769 std::process::exit(2);
770 }
771 }
772}
773
774#[cfg(test)]
775mod tests {
776 use super::*;
777 use crate::report::test_helpers::sample_results;
778 use fallow_core::extract::MemberKind;
779 use fallow_core::results::*;
780 use std::path::PathBuf;
781 use std::time::Duration;
782
783 #[test]
784 fn json_output_has_metadata_fields() {
785 let root = PathBuf::from("/project");
786 let results = AnalysisResults::default();
787 let elapsed = Duration::from_millis(123);
788 let output = build_json(&results, &root, elapsed).expect("should serialize");
789
790 assert_eq!(output["schema_version"], 3);
791 assert!(output["version"].is_string());
792 assert_eq!(output["elapsed_ms"], 123);
793 assert_eq!(output["total_issues"], 0);
794 }
795
796 #[test]
797 fn json_output_includes_issue_arrays() {
798 let root = PathBuf::from("/project");
799 let results = sample_results(&root);
800 let elapsed = Duration::from_millis(50);
801 let output = build_json(&results, &root, elapsed).expect("should serialize");
802
803 assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
804 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
805 assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
806 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
807 assert_eq!(
808 output["unused_dev_dependencies"].as_array().unwrap().len(),
809 1
810 );
811 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
812 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
813 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
814 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
815 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
816 assert_eq!(
817 output["type_only_dependencies"].as_array().unwrap().len(),
818 1
819 );
820 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
821 }
822
823 #[test]
824 fn json_metadata_fields_appear_first() {
825 let root = PathBuf::from("/project");
826 let results = AnalysisResults::default();
827 let elapsed = Duration::from_millis(0);
828 let output = build_json(&results, &root, elapsed).expect("should serialize");
829 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
830 assert_eq!(keys[0], "schema_version");
831 assert_eq!(keys[1], "version");
832 assert_eq!(keys[2], "elapsed_ms");
833 assert_eq!(keys[3], "total_issues");
834 }
835
836 #[test]
837 fn json_total_issues_matches_results() {
838 let root = PathBuf::from("/project");
839 let results = sample_results(&root);
840 let total = results.total_issues();
841 let elapsed = Duration::from_millis(0);
842 let output = build_json(&results, &root, elapsed).expect("should serialize");
843
844 assert_eq!(output["total_issues"], total);
845 }
846
847 #[test]
848 fn json_unused_export_contains_expected_fields() {
849 let root = PathBuf::from("/project");
850 let mut results = AnalysisResults::default();
851 results.unused_exports.push(UnusedExport {
852 path: root.join("src/utils.ts"),
853 export_name: "helperFn".to_string(),
854 is_type_only: false,
855 line: 10,
856 col: 4,
857 span_start: 120,
858 is_re_export: false,
859 });
860 let elapsed = Duration::from_millis(0);
861 let output = build_json(&results, &root, elapsed).expect("should serialize");
862
863 let export = &output["unused_exports"][0];
864 assert_eq!(export["export_name"], "helperFn");
865 assert_eq!(export["line"], 10);
866 assert_eq!(export["col"], 4);
867 assert_eq!(export["is_type_only"], false);
868 assert_eq!(export["span_start"], 120);
869 assert_eq!(export["is_re_export"], false);
870 }
871
872 #[test]
873 fn json_serializes_to_valid_json() {
874 let root = PathBuf::from("/project");
875 let results = sample_results(&root);
876 let elapsed = Duration::from_millis(42);
877 let output = build_json(&results, &root, elapsed).expect("should serialize");
878
879 let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
880 let reparsed: serde_json::Value =
881 serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
882 assert_eq!(reparsed, output);
883 }
884
885 #[test]
888 fn json_empty_results_produce_valid_structure() {
889 let root = PathBuf::from("/project");
890 let results = AnalysisResults::default();
891 let elapsed = Duration::from_millis(0);
892 let output = build_json(&results, &root, elapsed).expect("should serialize");
893
894 assert_eq!(output["total_issues"], 0);
895 assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
896 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
897 assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
898 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
899 assert_eq!(
900 output["unused_dev_dependencies"].as_array().unwrap().len(),
901 0
902 );
903 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
904 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
905 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
906 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
907 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
908 assert_eq!(
909 output["type_only_dependencies"].as_array().unwrap().len(),
910 0
911 );
912 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
913 }
914
915 #[test]
916 fn json_empty_results_round_trips_through_string() {
917 let root = PathBuf::from("/project");
918 let results = AnalysisResults::default();
919 let elapsed = Duration::from_millis(0);
920 let output = build_json(&results, &root, elapsed).expect("should serialize");
921
922 let json_str = serde_json::to_string(&output).expect("should stringify");
923 let reparsed: serde_json::Value =
924 serde_json::from_str(&json_str).expect("should parse back");
925 assert_eq!(reparsed["total_issues"], 0);
926 }
927
928 #[test]
931 fn json_paths_are_relative_to_root() {
932 let root = PathBuf::from("/project");
933 let mut results = AnalysisResults::default();
934 results.unused_files.push(UnusedFile {
935 path: root.join("src/deep/nested/file.ts"),
936 });
937 let elapsed = Duration::from_millis(0);
938 let output = build_json(&results, &root, elapsed).expect("should serialize");
939
940 let path = output["unused_files"][0]["path"].as_str().unwrap();
941 assert_eq!(path, "src/deep/nested/file.ts");
942 assert!(!path.starts_with("/project"));
943 }
944
945 #[test]
946 fn json_strips_root_from_nested_locations() {
947 let root = PathBuf::from("/project");
948 let mut results = AnalysisResults::default();
949 results.unlisted_dependencies.push(UnlistedDependency {
950 package_name: "chalk".to_string(),
951 imported_from: vec![ImportSite {
952 path: root.join("src/cli.ts"),
953 line: 2,
954 col: 0,
955 }],
956 });
957 let elapsed = Duration::from_millis(0);
958 let output = build_json(&results, &root, elapsed).expect("should serialize");
959
960 let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
961 .as_str()
962 .unwrap();
963 assert_eq!(site_path, "src/cli.ts");
964 }
965
966 #[test]
967 fn json_strips_root_from_duplicate_export_locations() {
968 let root = PathBuf::from("/project");
969 let mut results = AnalysisResults::default();
970 results.duplicate_exports.push(DuplicateExport {
971 export_name: "Config".to_string(),
972 locations: vec![
973 DuplicateLocation {
974 path: root.join("src/config.ts"),
975 line: 15,
976 col: 0,
977 },
978 DuplicateLocation {
979 path: root.join("src/types.ts"),
980 line: 30,
981 col: 0,
982 },
983 ],
984 });
985 let elapsed = Duration::from_millis(0);
986 let output = build_json(&results, &root, elapsed).expect("should serialize");
987
988 let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
989 .as_str()
990 .unwrap();
991 let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
992 .as_str()
993 .unwrap();
994 assert_eq!(loc0, "src/config.ts");
995 assert_eq!(loc1, "src/types.ts");
996 }
997
998 #[test]
999 fn json_strips_root_from_circular_dependency_files() {
1000 let root = PathBuf::from("/project");
1001 let mut results = AnalysisResults::default();
1002 results.circular_dependencies.push(CircularDependency {
1003 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1004 length: 2,
1005 line: 1,
1006 col: 0,
1007 });
1008 let elapsed = Duration::from_millis(0);
1009 let output = build_json(&results, &root, elapsed).expect("should serialize");
1010
1011 let files = output["circular_dependencies"][0]["files"]
1012 .as_array()
1013 .unwrap();
1014 assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
1015 assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
1016 }
1017
1018 #[test]
1019 fn json_path_outside_root_not_stripped() {
1020 let root = PathBuf::from("/project");
1021 let mut results = AnalysisResults::default();
1022 results.unused_files.push(UnusedFile {
1023 path: PathBuf::from("/other/project/src/file.ts"),
1024 });
1025 let elapsed = Duration::from_millis(0);
1026 let output = build_json(&results, &root, elapsed).expect("should serialize");
1027
1028 let path = output["unused_files"][0]["path"].as_str().unwrap();
1029 assert!(path.contains("/other/project/"));
1030 }
1031
1032 #[test]
1035 fn json_unused_file_contains_path() {
1036 let root = PathBuf::from("/project");
1037 let mut results = AnalysisResults::default();
1038 results.unused_files.push(UnusedFile {
1039 path: root.join("src/orphan.ts"),
1040 });
1041 let elapsed = Duration::from_millis(0);
1042 let output = build_json(&results, &root, elapsed).expect("should serialize");
1043
1044 let file = &output["unused_files"][0];
1045 assert_eq!(file["path"], "src/orphan.ts");
1046 }
1047
1048 #[test]
1049 fn json_unused_type_contains_expected_fields() {
1050 let root = PathBuf::from("/project");
1051 let mut results = AnalysisResults::default();
1052 results.unused_types.push(UnusedExport {
1053 path: root.join("src/types.ts"),
1054 export_name: "OldInterface".to_string(),
1055 is_type_only: true,
1056 line: 20,
1057 col: 0,
1058 span_start: 300,
1059 is_re_export: false,
1060 });
1061 let elapsed = Duration::from_millis(0);
1062 let output = build_json(&results, &root, elapsed).expect("should serialize");
1063
1064 let typ = &output["unused_types"][0];
1065 assert_eq!(typ["export_name"], "OldInterface");
1066 assert_eq!(typ["is_type_only"], true);
1067 assert_eq!(typ["line"], 20);
1068 assert_eq!(typ["path"], "src/types.ts");
1069 }
1070
1071 #[test]
1072 fn json_unused_dependency_contains_expected_fields() {
1073 let root = PathBuf::from("/project");
1074 let mut results = AnalysisResults::default();
1075 results.unused_dependencies.push(UnusedDependency {
1076 package_name: "axios".to_string(),
1077 location: DependencyLocation::Dependencies,
1078 path: root.join("package.json"),
1079 line: 10,
1080 });
1081 let elapsed = Duration::from_millis(0);
1082 let output = build_json(&results, &root, elapsed).expect("should serialize");
1083
1084 let dep = &output["unused_dependencies"][0];
1085 assert_eq!(dep["package_name"], "axios");
1086 assert_eq!(dep["line"], 10);
1087 }
1088
1089 #[test]
1090 fn json_unused_dev_dependency_contains_expected_fields() {
1091 let root = PathBuf::from("/project");
1092 let mut results = AnalysisResults::default();
1093 results.unused_dev_dependencies.push(UnusedDependency {
1094 package_name: "vitest".to_string(),
1095 location: DependencyLocation::DevDependencies,
1096 path: root.join("package.json"),
1097 line: 15,
1098 });
1099 let elapsed = Duration::from_millis(0);
1100 let output = build_json(&results, &root, elapsed).expect("should serialize");
1101
1102 let dep = &output["unused_dev_dependencies"][0];
1103 assert_eq!(dep["package_name"], "vitest");
1104 }
1105
1106 #[test]
1107 fn json_unused_optional_dependency_contains_expected_fields() {
1108 let root = PathBuf::from("/project");
1109 let mut results = AnalysisResults::default();
1110 results.unused_optional_dependencies.push(UnusedDependency {
1111 package_name: "fsevents".to_string(),
1112 location: DependencyLocation::OptionalDependencies,
1113 path: root.join("package.json"),
1114 line: 12,
1115 });
1116 let elapsed = Duration::from_millis(0);
1117 let output = build_json(&results, &root, elapsed).expect("should serialize");
1118
1119 let dep = &output["unused_optional_dependencies"][0];
1120 assert_eq!(dep["package_name"], "fsevents");
1121 assert_eq!(output["total_issues"], 1);
1122 }
1123
1124 #[test]
1125 fn json_unused_enum_member_contains_expected_fields() {
1126 let root = PathBuf::from("/project");
1127 let mut results = AnalysisResults::default();
1128 results.unused_enum_members.push(UnusedMember {
1129 path: root.join("src/enums.ts"),
1130 parent_name: "Color".to_string(),
1131 member_name: "Purple".to_string(),
1132 kind: MemberKind::EnumMember,
1133 line: 5,
1134 col: 2,
1135 });
1136 let elapsed = Duration::from_millis(0);
1137 let output = build_json(&results, &root, elapsed).expect("should serialize");
1138
1139 let member = &output["unused_enum_members"][0];
1140 assert_eq!(member["parent_name"], "Color");
1141 assert_eq!(member["member_name"], "Purple");
1142 assert_eq!(member["line"], 5);
1143 assert_eq!(member["path"], "src/enums.ts");
1144 }
1145
1146 #[test]
1147 fn json_unused_class_member_contains_expected_fields() {
1148 let root = PathBuf::from("/project");
1149 let mut results = AnalysisResults::default();
1150 results.unused_class_members.push(UnusedMember {
1151 path: root.join("src/api.ts"),
1152 parent_name: "ApiClient".to_string(),
1153 member_name: "deprecatedFetch".to_string(),
1154 kind: MemberKind::ClassMethod,
1155 line: 100,
1156 col: 4,
1157 });
1158 let elapsed = Duration::from_millis(0);
1159 let output = build_json(&results, &root, elapsed).expect("should serialize");
1160
1161 let member = &output["unused_class_members"][0];
1162 assert_eq!(member["parent_name"], "ApiClient");
1163 assert_eq!(member["member_name"], "deprecatedFetch");
1164 assert_eq!(member["line"], 100);
1165 }
1166
1167 #[test]
1168 fn json_unresolved_import_contains_expected_fields() {
1169 let root = PathBuf::from("/project");
1170 let mut results = AnalysisResults::default();
1171 results.unresolved_imports.push(UnresolvedImport {
1172 path: root.join("src/app.ts"),
1173 specifier: "@acme/missing-pkg".to_string(),
1174 line: 7,
1175 col: 0,
1176 specifier_col: 0,
1177 });
1178 let elapsed = Duration::from_millis(0);
1179 let output = build_json(&results, &root, elapsed).expect("should serialize");
1180
1181 let import = &output["unresolved_imports"][0];
1182 assert_eq!(import["specifier"], "@acme/missing-pkg");
1183 assert_eq!(import["line"], 7);
1184 assert_eq!(import["path"], "src/app.ts");
1185 }
1186
1187 #[test]
1188 fn json_unlisted_dependency_contains_import_sites() {
1189 let root = PathBuf::from("/project");
1190 let mut results = AnalysisResults::default();
1191 results.unlisted_dependencies.push(UnlistedDependency {
1192 package_name: "dotenv".to_string(),
1193 imported_from: vec![
1194 ImportSite {
1195 path: root.join("src/config.ts"),
1196 line: 1,
1197 col: 0,
1198 },
1199 ImportSite {
1200 path: root.join("src/server.ts"),
1201 line: 3,
1202 col: 0,
1203 },
1204 ],
1205 });
1206 let elapsed = Duration::from_millis(0);
1207 let output = build_json(&results, &root, elapsed).expect("should serialize");
1208
1209 let dep = &output["unlisted_dependencies"][0];
1210 assert_eq!(dep["package_name"], "dotenv");
1211 let sites = dep["imported_from"].as_array().unwrap();
1212 assert_eq!(sites.len(), 2);
1213 assert_eq!(sites[0]["path"], "src/config.ts");
1214 assert_eq!(sites[1]["path"], "src/server.ts");
1215 }
1216
1217 #[test]
1218 fn json_duplicate_export_contains_locations() {
1219 let root = PathBuf::from("/project");
1220 let mut results = AnalysisResults::default();
1221 results.duplicate_exports.push(DuplicateExport {
1222 export_name: "Button".to_string(),
1223 locations: vec![
1224 DuplicateLocation {
1225 path: root.join("src/ui.ts"),
1226 line: 10,
1227 col: 0,
1228 },
1229 DuplicateLocation {
1230 path: root.join("src/components.ts"),
1231 line: 25,
1232 col: 0,
1233 },
1234 ],
1235 });
1236 let elapsed = Duration::from_millis(0);
1237 let output = build_json(&results, &root, elapsed).expect("should serialize");
1238
1239 let dup = &output["duplicate_exports"][0];
1240 assert_eq!(dup["export_name"], "Button");
1241 let locs = dup["locations"].as_array().unwrap();
1242 assert_eq!(locs.len(), 2);
1243 assert_eq!(locs[0]["line"], 10);
1244 assert_eq!(locs[1]["line"], 25);
1245 }
1246
1247 #[test]
1248 fn json_type_only_dependency_contains_expected_fields() {
1249 let root = PathBuf::from("/project");
1250 let mut results = AnalysisResults::default();
1251 results.type_only_dependencies.push(TypeOnlyDependency {
1252 package_name: "zod".to_string(),
1253 path: root.join("package.json"),
1254 line: 8,
1255 });
1256 let elapsed = Duration::from_millis(0);
1257 let output = build_json(&results, &root, elapsed).expect("should serialize");
1258
1259 let dep = &output["type_only_dependencies"][0];
1260 assert_eq!(dep["package_name"], "zod");
1261 assert_eq!(dep["line"], 8);
1262 }
1263
1264 #[test]
1265 fn json_circular_dependency_contains_expected_fields() {
1266 let root = PathBuf::from("/project");
1267 let mut results = AnalysisResults::default();
1268 results.circular_dependencies.push(CircularDependency {
1269 files: vec![
1270 root.join("src/a.ts"),
1271 root.join("src/b.ts"),
1272 root.join("src/c.ts"),
1273 ],
1274 length: 3,
1275 line: 5,
1276 col: 0,
1277 });
1278 let elapsed = Duration::from_millis(0);
1279 let output = build_json(&results, &root, elapsed).expect("should serialize");
1280
1281 let cycle = &output["circular_dependencies"][0];
1282 assert_eq!(cycle["length"], 3);
1283 assert_eq!(cycle["line"], 5);
1284 let files = cycle["files"].as_array().unwrap();
1285 assert_eq!(files.len(), 3);
1286 }
1287
1288 #[test]
1291 fn json_re_export_flagged_correctly() {
1292 let root = PathBuf::from("/project");
1293 let mut results = AnalysisResults::default();
1294 results.unused_exports.push(UnusedExport {
1295 path: root.join("src/index.ts"),
1296 export_name: "reExported".to_string(),
1297 is_type_only: false,
1298 line: 1,
1299 col: 0,
1300 span_start: 0,
1301 is_re_export: true,
1302 });
1303 let elapsed = Duration::from_millis(0);
1304 let output = build_json(&results, &root, elapsed).expect("should serialize");
1305
1306 assert_eq!(output["unused_exports"][0]["is_re_export"], true);
1307 }
1308
1309 #[test]
1312 fn json_schema_version_is_3() {
1313 let root = PathBuf::from("/project");
1314 let results = AnalysisResults::default();
1315 let elapsed = Duration::from_millis(0);
1316 let output = build_json(&results, &root, elapsed).expect("should serialize");
1317
1318 assert_eq!(output["schema_version"], SCHEMA_VERSION);
1319 assert_eq!(output["schema_version"], 3);
1320 }
1321
1322 #[test]
1325 fn json_version_matches_cargo_pkg_version() {
1326 let root = PathBuf::from("/project");
1327 let results = AnalysisResults::default();
1328 let elapsed = Duration::from_millis(0);
1329 let output = build_json(&results, &root, elapsed).expect("should serialize");
1330
1331 assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
1332 }
1333
1334 #[test]
1337 fn json_elapsed_ms_zero_duration() {
1338 let root = PathBuf::from("/project");
1339 let results = AnalysisResults::default();
1340 let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
1341
1342 assert_eq!(output["elapsed_ms"], 0);
1343 }
1344
1345 #[test]
1346 fn json_elapsed_ms_large_duration() {
1347 let root = PathBuf::from("/project");
1348 let results = AnalysisResults::default();
1349 let elapsed = Duration::from_secs(120);
1350 let output = build_json(&results, &root, elapsed).expect("should serialize");
1351
1352 assert_eq!(output["elapsed_ms"], 120_000);
1353 }
1354
1355 #[test]
1356 fn json_elapsed_ms_sub_millisecond_truncated() {
1357 let root = PathBuf::from("/project");
1358 let results = AnalysisResults::default();
1359 let elapsed = Duration::from_micros(500);
1361 let output = build_json(&results, &root, elapsed).expect("should serialize");
1362
1363 assert_eq!(output["elapsed_ms"], 0);
1364 }
1365
1366 #[test]
1369 fn json_multiple_unused_files() {
1370 let root = PathBuf::from("/project");
1371 let mut results = AnalysisResults::default();
1372 results.unused_files.push(UnusedFile {
1373 path: root.join("src/a.ts"),
1374 });
1375 results.unused_files.push(UnusedFile {
1376 path: root.join("src/b.ts"),
1377 });
1378 results.unused_files.push(UnusedFile {
1379 path: root.join("src/c.ts"),
1380 });
1381 let elapsed = Duration::from_millis(0);
1382 let output = build_json(&results, &root, elapsed).expect("should serialize");
1383
1384 assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
1385 assert_eq!(output["total_issues"], 3);
1386 }
1387
1388 #[test]
1391 fn strip_root_prefix_on_string_value() {
1392 let mut value = serde_json::json!("/project/src/file.ts");
1393 strip_root_prefix(&mut value, "/project/");
1394 assert_eq!(value, "src/file.ts");
1395 }
1396
1397 #[test]
1398 fn strip_root_prefix_leaves_non_matching_string() {
1399 let mut value = serde_json::json!("/other/src/file.ts");
1400 strip_root_prefix(&mut value, "/project/");
1401 assert_eq!(value, "/other/src/file.ts");
1402 }
1403
1404 #[test]
1405 fn strip_root_prefix_recurses_into_arrays() {
1406 let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
1407 strip_root_prefix(&mut value, "/project/");
1408 assert_eq!(value[0], "a.ts");
1409 assert_eq!(value[1], "b.ts");
1410 assert_eq!(value[2], "/other/c.ts");
1411 }
1412
1413 #[test]
1414 fn strip_root_prefix_recurses_into_nested_objects() {
1415 let mut value = serde_json::json!({
1416 "outer": {
1417 "path": "/project/src/nested.ts"
1418 }
1419 });
1420 strip_root_prefix(&mut value, "/project/");
1421 assert_eq!(value["outer"]["path"], "src/nested.ts");
1422 }
1423
1424 #[test]
1425 fn strip_root_prefix_leaves_numbers_and_booleans() {
1426 let mut value = serde_json::json!({
1427 "line": 42,
1428 "is_type_only": false,
1429 "path": "/project/src/file.ts"
1430 });
1431 strip_root_prefix(&mut value, "/project/");
1432 assert_eq!(value["line"], 42);
1433 assert_eq!(value["is_type_only"], false);
1434 assert_eq!(value["path"], "src/file.ts");
1435 }
1436
1437 #[test]
1438 fn strip_root_prefix_handles_empty_string_after_strip() {
1439 let mut value = serde_json::json!("/project/");
1442 strip_root_prefix(&mut value, "/project/");
1443 assert_eq!(value, "");
1444 }
1445
1446 #[test]
1447 fn strip_root_prefix_deeply_nested_array_of_objects() {
1448 let mut value = serde_json::json!({
1449 "groups": [{
1450 "instances": [{
1451 "file": "/project/src/a.ts"
1452 }, {
1453 "file": "/project/src/b.ts"
1454 }]
1455 }]
1456 });
1457 strip_root_prefix(&mut value, "/project/");
1458 assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
1459 assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
1460 }
1461
1462 #[test]
1465 fn json_full_sample_results_total_issues_correct() {
1466 let root = PathBuf::from("/project");
1467 let results = sample_results(&root);
1468 let elapsed = Duration::from_millis(100);
1469 let output = build_json(&results, &root, elapsed).expect("should serialize");
1470
1471 assert_eq!(output["total_issues"], results.total_issues());
1477 }
1478
1479 #[test]
1480 fn json_full_sample_no_absolute_paths_in_output() {
1481 let root = PathBuf::from("/project");
1482 let results = sample_results(&root);
1483 let elapsed = Duration::from_millis(0);
1484 let output = build_json(&results, &root, elapsed).expect("should serialize");
1485
1486 let json_str = serde_json::to_string(&output).expect("should stringify");
1487 assert!(!json_str.contains("/project/src/"));
1489 assert!(!json_str.contains("/project/package.json"));
1490 }
1491
1492 #[test]
1495 fn json_output_is_deterministic() {
1496 let root = PathBuf::from("/project");
1497 let results = sample_results(&root);
1498 let elapsed = Duration::from_millis(50);
1499
1500 let output1 = build_json(&results, &root, elapsed).expect("first build");
1501 let output2 = build_json(&results, &root, elapsed).expect("second build");
1502
1503 assert_eq!(output1, output2);
1504 }
1505
1506 #[test]
1509 fn json_results_fields_do_not_shadow_metadata() {
1510 let root = PathBuf::from("/project");
1513 let results = AnalysisResults::default();
1514 let elapsed = Duration::from_millis(99);
1515 let output = build_json(&results, &root, elapsed).expect("should serialize");
1516
1517 assert_eq!(output["schema_version"], 3);
1519 assert_eq!(output["elapsed_ms"], 99);
1520 }
1521
1522 #[test]
1525 fn json_all_issue_type_arrays_present_in_empty_results() {
1526 let root = PathBuf::from("/project");
1527 let results = AnalysisResults::default();
1528 let elapsed = Duration::from_millis(0);
1529 let output = build_json(&results, &root, elapsed).expect("should serialize");
1530
1531 let expected_arrays = [
1532 "unused_files",
1533 "unused_exports",
1534 "unused_types",
1535 "unused_dependencies",
1536 "unused_dev_dependencies",
1537 "unused_optional_dependencies",
1538 "unused_enum_members",
1539 "unused_class_members",
1540 "unresolved_imports",
1541 "unlisted_dependencies",
1542 "duplicate_exports",
1543 "type_only_dependencies",
1544 "test_only_dependencies",
1545 "circular_dependencies",
1546 ];
1547 for key in &expected_arrays {
1548 assert!(
1549 output[key].is_array(),
1550 "expected '{key}' to be an array in JSON output"
1551 );
1552 }
1553 }
1554
1555 #[test]
1558 fn insert_meta_adds_key_to_object() {
1559 let mut output = serde_json::json!({ "foo": 1 });
1560 let meta = serde_json::json!({ "docs": "https://example.com" });
1561 insert_meta(&mut output, meta.clone());
1562 assert_eq!(output["_meta"], meta);
1563 }
1564
1565 #[test]
1566 fn insert_meta_noop_on_non_object() {
1567 let mut output = serde_json::json!([1, 2, 3]);
1568 let meta = serde_json::json!({ "docs": "https://example.com" });
1569 insert_meta(&mut output, meta);
1570 assert!(output.is_array());
1572 }
1573
1574 #[test]
1575 fn insert_meta_overwrites_existing_meta() {
1576 let mut output = serde_json::json!({ "_meta": "old" });
1577 let meta = serde_json::json!({ "new": true });
1578 insert_meta(&mut output, meta.clone());
1579 assert_eq!(output["_meta"], meta);
1580 }
1581
1582 #[test]
1585 fn build_json_envelope_has_metadata_fields() {
1586 let report = serde_json::json!({ "findings": [] });
1587 let elapsed = Duration::from_millis(42);
1588 let output = build_json_envelope(report, elapsed);
1589
1590 assert_eq!(output["schema_version"], 3);
1591 assert!(output["version"].is_string());
1592 assert_eq!(output["elapsed_ms"], 42);
1593 assert!(output["findings"].is_array());
1594 }
1595
1596 #[test]
1597 fn build_json_envelope_metadata_appears_first() {
1598 let report = serde_json::json!({ "data": "value" });
1599 let output = build_json_envelope(report, Duration::from_millis(10));
1600
1601 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
1602 assert_eq!(keys[0], "schema_version");
1603 assert_eq!(keys[1], "version");
1604 assert_eq!(keys[2], "elapsed_ms");
1605 }
1606
1607 #[test]
1608 fn build_json_envelope_non_object_report() {
1609 let report = serde_json::json!("not an object");
1611 let output = build_json_envelope(report, Duration::from_millis(0));
1612
1613 let obj = output.as_object().unwrap();
1614 assert_eq!(obj.len(), 3);
1615 assert!(obj.contains_key("schema_version"));
1616 assert!(obj.contains_key("version"));
1617 assert!(obj.contains_key("elapsed_ms"));
1618 }
1619
1620 #[test]
1623 fn strip_root_prefix_null_unchanged() {
1624 let mut value = serde_json::Value::Null;
1625 strip_root_prefix(&mut value, "/project/");
1626 assert!(value.is_null());
1627 }
1628
1629 #[test]
1632 fn strip_root_prefix_empty_string() {
1633 let mut value = serde_json::json!("");
1634 strip_root_prefix(&mut value, "/project/");
1635 assert_eq!(value, "");
1636 }
1637
1638 #[test]
1641 fn strip_root_prefix_mixed_types() {
1642 let mut value = serde_json::json!({
1643 "path": "/project/src/file.ts",
1644 "line": 42,
1645 "flag": true,
1646 "nested": {
1647 "items": ["/project/a.ts", 99, null, "/project/b.ts"],
1648 "deep": { "path": "/project/c.ts" }
1649 }
1650 });
1651 strip_root_prefix(&mut value, "/project/");
1652 assert_eq!(value["path"], "src/file.ts");
1653 assert_eq!(value["line"], 42);
1654 assert_eq!(value["flag"], true);
1655 assert_eq!(value["nested"]["items"][0], "a.ts");
1656 assert_eq!(value["nested"]["items"][1], 99);
1657 assert!(value["nested"]["items"][2].is_null());
1658 assert_eq!(value["nested"]["items"][3], "b.ts");
1659 assert_eq!(value["nested"]["deep"]["path"], "c.ts");
1660 }
1661
1662 #[test]
1665 fn json_check_meta_integrates_correctly() {
1666 let root = PathBuf::from("/project");
1667 let results = AnalysisResults::default();
1668 let elapsed = Duration::from_millis(0);
1669 let mut output = build_json(&results, &root, elapsed).expect("should serialize");
1670 insert_meta(&mut output, crate::explain::check_meta());
1671
1672 assert!(output["_meta"]["docs"].is_string());
1673 assert!(output["_meta"]["rules"].is_object());
1674 }
1675
1676 #[test]
1679 fn json_unused_member_kind_serialized() {
1680 let root = PathBuf::from("/project");
1681 let mut results = AnalysisResults::default();
1682 results.unused_enum_members.push(UnusedMember {
1683 path: root.join("src/enums.ts"),
1684 parent_name: "Color".to_string(),
1685 member_name: "Red".to_string(),
1686 kind: MemberKind::EnumMember,
1687 line: 3,
1688 col: 2,
1689 });
1690 results.unused_class_members.push(UnusedMember {
1691 path: root.join("src/class.ts"),
1692 parent_name: "Foo".to_string(),
1693 member_name: "bar".to_string(),
1694 kind: MemberKind::ClassMethod,
1695 line: 10,
1696 col: 4,
1697 });
1698
1699 let elapsed = Duration::from_millis(0);
1700 let output = build_json(&results, &root, elapsed).expect("should serialize");
1701
1702 let enum_member = &output["unused_enum_members"][0];
1703 assert!(enum_member["kind"].is_string());
1704 let class_member = &output["unused_class_members"][0];
1705 assert!(class_member["kind"].is_string());
1706 }
1707
1708 #[test]
1711 fn json_unused_export_has_actions() {
1712 let root = PathBuf::from("/project");
1713 let mut results = AnalysisResults::default();
1714 results.unused_exports.push(UnusedExport {
1715 path: root.join("src/utils.ts"),
1716 export_name: "helperFn".to_string(),
1717 is_type_only: false,
1718 line: 10,
1719 col: 4,
1720 span_start: 120,
1721 is_re_export: false,
1722 });
1723 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1724
1725 let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
1726 assert_eq!(actions.len(), 2);
1727
1728 assert_eq!(actions[0]["type"], "remove-export");
1730 assert_eq!(actions[0]["auto_fixable"], true);
1731 assert!(actions[0].get("note").is_none());
1732
1733 assert_eq!(actions[1]["type"], "suppress-line");
1735 assert_eq!(
1736 actions[1]["comment"],
1737 "// fallow-ignore-next-line unused-export"
1738 );
1739 }
1740
1741 #[test]
1742 fn json_unused_file_has_file_suppress_and_note() {
1743 let root = PathBuf::from("/project");
1744 let mut results = AnalysisResults::default();
1745 results.unused_files.push(UnusedFile {
1746 path: root.join("src/dead.ts"),
1747 });
1748 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1749
1750 let actions = output["unused_files"][0]["actions"].as_array().unwrap();
1751 assert_eq!(actions[0]["type"], "delete-file");
1752 assert_eq!(actions[0]["auto_fixable"], false);
1753 assert!(actions[0]["note"].is_string());
1754 assert_eq!(actions[1]["type"], "suppress-file");
1755 assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
1756 }
1757
1758 #[test]
1759 fn json_unused_dependency_has_config_suppress_with_package_name() {
1760 let root = PathBuf::from("/project");
1761 let mut results = AnalysisResults::default();
1762 results.unused_dependencies.push(UnusedDependency {
1763 package_name: "lodash".to_string(),
1764 location: DependencyLocation::Dependencies,
1765 path: root.join("package.json"),
1766 line: 5,
1767 });
1768 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1769
1770 let actions = output["unused_dependencies"][0]["actions"]
1771 .as_array()
1772 .unwrap();
1773 assert_eq!(actions[0]["type"], "remove-dependency");
1774 assert_eq!(actions[0]["auto_fixable"], true);
1775
1776 assert_eq!(actions[1]["type"], "add-to-config");
1778 assert_eq!(actions[1]["config_key"], "ignoreDependencies");
1779 assert_eq!(actions[1]["value"], "lodash");
1780 }
1781
1782 #[test]
1783 fn json_empty_results_have_no_actions_in_empty_arrays() {
1784 let root = PathBuf::from("/project");
1785 let results = AnalysisResults::default();
1786 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1787
1788 assert!(output["unused_exports"].as_array().unwrap().is_empty());
1790 assert!(output["unused_files"].as_array().unwrap().is_empty());
1791 }
1792
1793 #[test]
1794 fn json_all_issue_types_have_actions() {
1795 let root = PathBuf::from("/project");
1796 let results = sample_results(&root);
1797 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1798
1799 let issue_keys = [
1800 "unused_files",
1801 "unused_exports",
1802 "unused_types",
1803 "unused_dependencies",
1804 "unused_dev_dependencies",
1805 "unused_optional_dependencies",
1806 "unused_enum_members",
1807 "unused_class_members",
1808 "unresolved_imports",
1809 "unlisted_dependencies",
1810 "duplicate_exports",
1811 "type_only_dependencies",
1812 "test_only_dependencies",
1813 "circular_dependencies",
1814 ];
1815
1816 for key in &issue_keys {
1817 let arr = output[key].as_array().unwrap();
1818 if !arr.is_empty() {
1819 let actions = arr[0]["actions"].as_array();
1820 assert!(
1821 actions.is_some() && !actions.unwrap().is_empty(),
1822 "missing actions for {key}"
1823 );
1824 }
1825 }
1826 }
1827
1828 #[test]
1831 fn health_finding_has_actions() {
1832 let mut output = serde_json::json!({
1833 "findings": [{
1834 "path": "src/utils.ts",
1835 "name": "processData",
1836 "line": 10,
1837 "col": 0,
1838 "cyclomatic": 25,
1839 "cognitive": 30,
1840 "line_count": 150,
1841 "exceeded": "both"
1842 }]
1843 });
1844
1845 inject_health_actions(&mut output);
1846
1847 let actions = output["findings"][0]["actions"].as_array().unwrap();
1848 assert_eq!(actions.len(), 2);
1849 assert_eq!(actions[0]["type"], "refactor-function");
1850 assert_eq!(actions[0]["auto_fixable"], false);
1851 assert!(
1852 actions[0]["description"]
1853 .as_str()
1854 .unwrap()
1855 .contains("processData")
1856 );
1857 assert_eq!(actions[1]["type"], "suppress-line");
1858 assert_eq!(
1859 actions[1]["comment"],
1860 "// fallow-ignore-next-line complexity"
1861 );
1862 }
1863
1864 #[test]
1865 fn refactoring_target_has_actions() {
1866 let mut output = serde_json::json!({
1867 "targets": [{
1868 "path": "src/big-module.ts",
1869 "priority": 85.0,
1870 "efficiency": 42.5,
1871 "recommendation": "Split module: 12 exports, 4 unused",
1872 "category": "split_high_impact",
1873 "effort": "medium",
1874 "confidence": "high",
1875 "evidence": { "unused_exports": 4 }
1876 }]
1877 });
1878
1879 inject_health_actions(&mut output);
1880
1881 let actions = output["targets"][0]["actions"].as_array().unwrap();
1882 assert_eq!(actions.len(), 2);
1883 assert_eq!(actions[0]["type"], "apply-refactoring");
1884 assert_eq!(
1885 actions[0]["description"],
1886 "Split module: 12 exports, 4 unused"
1887 );
1888 assert_eq!(actions[0]["category"], "split_high_impact");
1889 assert_eq!(actions[1]["type"], "suppress-line");
1891 }
1892
1893 #[test]
1894 fn refactoring_target_without_evidence_has_no_suppress() {
1895 let mut output = serde_json::json!({
1896 "targets": [{
1897 "path": "src/simple.ts",
1898 "priority": 30.0,
1899 "efficiency": 15.0,
1900 "recommendation": "Consider extracting helper functions",
1901 "category": "extract_complex_functions",
1902 "effort": "small",
1903 "confidence": "medium"
1904 }]
1905 });
1906
1907 inject_health_actions(&mut output);
1908
1909 let actions = output["targets"][0]["actions"].as_array().unwrap();
1910 assert_eq!(actions.len(), 1);
1911 assert_eq!(actions[0]["type"], "apply-refactoring");
1912 }
1913
1914 #[test]
1915 fn health_empty_findings_no_actions() {
1916 let mut output = serde_json::json!({
1917 "findings": [],
1918 "targets": []
1919 });
1920
1921 inject_health_actions(&mut output);
1922
1923 assert!(output["findings"].as_array().unwrap().is_empty());
1924 assert!(output["targets"].as_array().unwrap().is_empty());
1925 }
1926
1927 #[test]
1928 fn hotspot_has_actions() {
1929 let mut output = serde_json::json!({
1930 "hotspots": [{
1931 "path": "src/utils.ts",
1932 "complexity_score": 45.0,
1933 "churn_score": 12,
1934 "hotspot_score": 540.0
1935 }]
1936 });
1937
1938 inject_health_actions(&mut output);
1939
1940 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
1941 assert_eq!(actions.len(), 2);
1942 assert_eq!(actions[0]["type"], "refactor-file");
1943 assert!(
1944 actions[0]["description"]
1945 .as_str()
1946 .unwrap()
1947 .contains("src/utils.ts")
1948 );
1949 assert_eq!(actions[1]["type"], "add-tests");
1950 }
1951
1952 #[test]
1953 fn health_finding_suppress_has_placement() {
1954 let mut output = serde_json::json!({
1955 "findings": [{
1956 "path": "src/utils.ts",
1957 "name": "processData",
1958 "line": 10,
1959 "col": 0,
1960 "cyclomatic": 25,
1961 "cognitive": 30,
1962 "line_count": 150,
1963 "exceeded": "both"
1964 }]
1965 });
1966
1967 inject_health_actions(&mut output);
1968
1969 let suppress = &output["findings"][0]["actions"][1];
1970 assert_eq!(suppress["placement"], "above-function-declaration");
1971 }
1972
1973 #[test]
1976 fn clone_family_has_actions() {
1977 let mut output = serde_json::json!({
1978 "clone_families": [{
1979 "files": ["src/a.ts", "src/b.ts"],
1980 "groups": [
1981 { "instances": [{"file": "src/a.ts"}, {"file": "src/b.ts"}], "token_count": 100, "line_count": 20 }
1982 ],
1983 "total_duplicated_lines": 20,
1984 "total_duplicated_tokens": 100,
1985 "suggestions": [
1986 { "kind": "ExtractFunction", "description": "Extract shared validation logic", "estimated_savings": 15 }
1987 ]
1988 }]
1989 });
1990
1991 inject_dupes_actions(&mut output);
1992
1993 let actions = output["clone_families"][0]["actions"].as_array().unwrap();
1994 assert_eq!(actions.len(), 3);
1995 assert_eq!(actions[0]["type"], "extract-shared");
1996 assert_eq!(actions[0]["auto_fixable"], false);
1997 assert!(
1998 actions[0]["description"]
1999 .as_str()
2000 .unwrap()
2001 .contains("20 lines")
2002 );
2003 assert_eq!(actions[1]["type"], "apply-suggestion");
2005 assert!(
2006 actions[1]["description"]
2007 .as_str()
2008 .unwrap()
2009 .contains("validation logic")
2010 );
2011 assert_eq!(actions[2]["type"], "suppress-line");
2013 assert_eq!(
2014 actions[2]["comment"],
2015 "// fallow-ignore-next-line code-duplication"
2016 );
2017 }
2018
2019 #[test]
2020 fn clone_group_has_actions() {
2021 let mut output = serde_json::json!({
2022 "clone_groups": [{
2023 "instances": [
2024 {"file": "src/a.ts", "start_line": 1, "end_line": 10},
2025 {"file": "src/b.ts", "start_line": 5, "end_line": 14}
2026 ],
2027 "token_count": 50,
2028 "line_count": 10
2029 }]
2030 });
2031
2032 inject_dupes_actions(&mut output);
2033
2034 let actions = output["clone_groups"][0]["actions"].as_array().unwrap();
2035 assert_eq!(actions.len(), 2);
2036 assert_eq!(actions[0]["type"], "extract-shared");
2037 assert!(
2038 actions[0]["description"]
2039 .as_str()
2040 .unwrap()
2041 .contains("10 lines")
2042 );
2043 assert!(
2044 actions[0]["description"]
2045 .as_str()
2046 .unwrap()
2047 .contains("2 instances")
2048 );
2049 assert_eq!(actions[1]["type"], "suppress-line");
2050 }
2051
2052 #[test]
2053 fn dupes_empty_results_no_actions() {
2054 let mut output = serde_json::json!({
2055 "clone_families": [],
2056 "clone_groups": []
2057 });
2058
2059 inject_dupes_actions(&mut output);
2060
2061 assert!(output["clone_families"].as_array().unwrap().is_empty());
2062 assert!(output["clone_groups"].as_array().unwrap().is_empty());
2063 }
2064}