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