1#[derive(Debug, serde::Serialize)]
9pub struct HealthReport {
10 pub findings: Vec<HealthFinding>,
12 pub summary: HealthSummary,
14 #[serde(skip_serializing_if = "Option::is_none")]
16 pub vital_signs: Option<VitalSigns>,
17 #[serde(skip_serializing_if = "Vec::is_empty")]
19 pub file_scores: Vec<FileHealthScore>,
20 #[serde(skip_serializing_if = "Vec::is_empty")]
22 pub hotspots: Vec<HotspotEntry>,
23 #[serde(skip_serializing_if = "Option::is_none")]
25 pub hotspot_summary: Option<HotspotSummary>,
26 #[serde(skip_serializing_if = "Vec::is_empty")]
28 pub targets: Vec<RefactoringTarget>,
29 #[serde(skip_serializing_if = "Option::is_none")]
31 pub target_thresholds: Option<TargetThresholds>,
32}
33
34#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
40pub struct VitalSigns {
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub dead_file_pct: Option<f64>,
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub dead_export_pct: Option<f64>,
47 pub avg_cyclomatic: f64,
49 pub p90_cyclomatic: u32,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub duplication_pct: Option<f64>,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub hotspot_count: Option<u32>,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub maintainability_avg: Option<f64>,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub unused_dep_count: Option<u32>,
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub circular_dep_count: Option<u32>,
66}
67
68#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
73pub struct VitalSignsCounts {
74 pub total_files: usize,
75 pub total_exports: usize,
76 pub dead_files: usize,
77 pub dead_exports: usize,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub duplicated_lines: Option<usize>,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub total_lines: Option<usize>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub files_scored: Option<usize>,
84 pub total_deps: usize,
85}
86
87#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
89pub struct VitalSignsSnapshot {
90 pub snapshot_schema_version: u32,
92 pub version: String,
94 pub timestamp: String,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub git_sha: Option<String>,
99 #[serde(skip_serializing_if = "Option::is_none")]
101 pub git_branch: Option<String>,
102 #[serde(default)]
104 pub shallow_clone: bool,
105 pub vital_signs: VitalSigns,
107 pub counts: VitalSignsCounts,
109}
110
111pub const SNAPSHOT_SCHEMA_VERSION: u32 = 1;
113
114pub const HOTSPOT_SCORE_THRESHOLD: f64 = 50.0;
116
117#[derive(Debug, serde::Serialize)]
119pub struct HealthFinding {
120 pub path: std::path::PathBuf,
122 pub name: String,
124 pub line: u32,
126 pub col: u32,
128 pub cyclomatic: u16,
130 pub cognitive: u16,
132 pub line_count: u32,
134 pub exceeded: ExceededThreshold,
136}
137
138#[derive(Debug, serde::Serialize)]
140#[serde(rename_all = "snake_case")]
141pub enum ExceededThreshold {
142 Cyclomatic,
144 Cognitive,
146 Both,
148}
149
150#[derive(Debug, serde::Serialize)]
152pub struct HealthSummary {
153 pub files_analyzed: usize,
155 pub functions_analyzed: usize,
157 pub functions_above_threshold: usize,
159 pub max_cyclomatic_threshold: u16,
161 pub max_cognitive_threshold: u16,
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub files_scored: Option<usize>,
166 #[serde(skip_serializing_if = "Option::is_none")]
168 pub average_maintainability: Option<f64>,
169}
170
171#[derive(Debug, Clone, serde::Serialize)]
191pub struct FileHealthScore {
192 pub path: std::path::PathBuf,
194 pub fan_in: usize,
196 pub fan_out: usize,
198 pub dead_code_ratio: f64,
202 pub complexity_density: f64,
204 pub maintainability_index: f64,
206 pub total_cyclomatic: u32,
208 pub total_cognitive: u32,
210 pub function_count: usize,
212 pub lines: u32,
214}
215
216#[derive(Debug, Clone, serde::Serialize)]
229pub struct HotspotEntry {
230 pub path: std::path::PathBuf,
232 pub score: f64,
234 pub commits: u32,
236 pub weighted_commits: f64,
238 pub lines_added: u32,
240 pub lines_deleted: u32,
242 pub complexity_density: f64,
244 pub fan_in: usize,
246 pub trend: fallow_core::churn::ChurnTrend,
248}
249
250#[derive(Debug, serde::Serialize)]
252pub struct HotspotSummary {
253 pub since: String,
255 pub min_commits: u32,
257 pub files_analyzed: usize,
259 pub files_excluded: usize,
261 pub shallow_clone: bool,
263}
264
265#[derive(Debug, Clone, serde::Serialize)]
270#[allow(clippy::struct_field_names)] pub struct TargetThresholds {
272 pub fan_in_p95: f64,
274 pub fan_in_p75: f64,
276 pub fan_out_p95: f64,
278 pub fan_out_p90: usize,
280}
281
282#[derive(Debug, Clone, serde::Serialize)]
284#[serde(rename_all = "snake_case")]
285pub enum RecommendationCategory {
286 UrgentChurnComplexity,
288 BreakCircularDependency,
290 SplitHighImpact,
292 RemoveDeadCode,
294 ExtractComplexFunctions,
296 ExtractDependencies,
298}
299
300impl RecommendationCategory {
301 pub fn label(&self) -> &'static str {
303 match self {
304 Self::UrgentChurnComplexity => "churn+complexity",
305 Self::BreakCircularDependency => "circular dep",
306 Self::SplitHighImpact => "high impact",
307 Self::RemoveDeadCode => "dead code",
308 Self::ExtractComplexFunctions => "complexity",
309 Self::ExtractDependencies => "coupling",
310 }
311 }
312
313 pub fn compact_label(&self) -> &'static str {
315 match self {
316 Self::UrgentChurnComplexity => "churn_complexity",
317 Self::BreakCircularDependency => "circular_dep",
318 Self::SplitHighImpact => "high_impact",
319 Self::RemoveDeadCode => "dead_code",
320 Self::ExtractComplexFunctions => "complexity",
321 Self::ExtractDependencies => "coupling",
322 }
323 }
324}
325
326#[derive(Debug, Clone, serde::Serialize)]
328pub struct ContributingFactor {
329 pub metric: &'static str,
331 pub value: f64,
333 pub threshold: f64,
335 pub detail: String,
337}
338
339#[derive(Debug, Clone, serde::Serialize)]
359#[serde(rename_all = "snake_case")]
360pub enum EffortEstimate {
361 Low,
363 Medium,
365 High,
367}
368
369impl EffortEstimate {
370 pub fn label(&self) -> &'static str {
372 match self {
373 Self::Low => "low",
374 Self::Medium => "medium",
375 Self::High => "high",
376 }
377 }
378
379 pub fn numeric(&self) -> f64 {
381 match self {
382 Self::Low => 1.0,
383 Self::Medium => 2.0,
384 Self::High => 3.0,
385 }
386 }
387}
388
389#[derive(Debug, Clone, serde::Serialize)]
396#[serde(rename_all = "snake_case")]
397pub enum Confidence {
398 High,
400 Medium,
402 Low,
404}
405
406impl Confidence {
407 pub fn label(&self) -> &'static str {
409 match self {
410 Self::High => "high",
411 Self::Medium => "medium",
412 Self::Low => "low",
413 }
414 }
415}
416
417#[derive(Debug, Clone, serde::Serialize)]
422pub struct TargetEvidence {
423 #[serde(skip_serializing_if = "Vec::is_empty")]
425 pub unused_exports: Vec<String>,
426 #[serde(skip_serializing_if = "Vec::is_empty")]
428 pub complex_functions: Vec<EvidenceFunction>,
429 #[serde(skip_serializing_if = "Vec::is_empty")]
431 pub cycle_path: Vec<String>,
432}
433
434#[derive(Debug, Clone, serde::Serialize)]
436pub struct EvidenceFunction {
437 pub name: String,
439 pub line: u32,
441 pub cognitive: u16,
443}
444
445#[derive(Debug, Clone, serde::Serialize)]
446pub struct RefactoringTarget {
447 pub path: std::path::PathBuf,
449 pub priority: f64,
451 pub efficiency: f64,
454 pub recommendation: String,
456 pub category: RecommendationCategory,
458 pub effort: EffortEstimate,
460 pub confidence: Confidence,
462 #[serde(skip_serializing_if = "Vec::is_empty")]
464 pub factors: Vec<ContributingFactor>,
465 #[serde(skip_serializing_if = "Option::is_none")]
467 pub evidence: Option<TargetEvidence>,
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 #[test]
477 fn category_labels_are_non_empty() {
478 let categories = [
479 RecommendationCategory::UrgentChurnComplexity,
480 RecommendationCategory::BreakCircularDependency,
481 RecommendationCategory::SplitHighImpact,
482 RecommendationCategory::RemoveDeadCode,
483 RecommendationCategory::ExtractComplexFunctions,
484 RecommendationCategory::ExtractDependencies,
485 ];
486 for cat in &categories {
487 assert!(!cat.label().is_empty(), "{cat:?} should have a label");
488 }
489 }
490
491 #[test]
492 fn category_labels_are_unique() {
493 let categories = [
494 RecommendationCategory::UrgentChurnComplexity,
495 RecommendationCategory::BreakCircularDependency,
496 RecommendationCategory::SplitHighImpact,
497 RecommendationCategory::RemoveDeadCode,
498 RecommendationCategory::ExtractComplexFunctions,
499 RecommendationCategory::ExtractDependencies,
500 ];
501 let labels: Vec<&str> = categories.iter().map(|c| c.label()).collect();
502 let unique: std::collections::HashSet<&&str> = labels.iter().collect();
503 assert_eq!(labels.len(), unique.len(), "category labels must be unique");
504 }
505
506 #[test]
509 fn category_serializes_as_snake_case() {
510 let json = serde_json::to_string(&RecommendationCategory::UrgentChurnComplexity).unwrap();
511 assert_eq!(json, r#""urgent_churn_complexity""#);
512
513 let json = serde_json::to_string(&RecommendationCategory::BreakCircularDependency).unwrap();
514 assert_eq!(json, r#""break_circular_dependency""#);
515 }
516
517 #[test]
518 fn exceeded_threshold_serializes_as_snake_case() {
519 let json = serde_json::to_string(&ExceededThreshold::Both).unwrap();
520 assert_eq!(json, r#""both""#);
521
522 let json = serde_json::to_string(&ExceededThreshold::Cyclomatic).unwrap();
523 assert_eq!(json, r#""cyclomatic""#);
524 }
525
526 #[test]
527 fn health_report_skips_empty_collections() {
528 let report = HealthReport {
529 findings: vec![],
530 summary: HealthSummary {
531 files_analyzed: 0,
532 functions_analyzed: 0,
533 functions_above_threshold: 0,
534 max_cyclomatic_threshold: 20,
535 max_cognitive_threshold: 15,
536 files_scored: None,
537 average_maintainability: None,
538 },
539 vital_signs: None,
540 file_scores: vec![],
541 hotspots: vec![],
542 hotspot_summary: None,
543 targets: vec![],
544 target_thresholds: None,
545 };
546 let json = serde_json::to_string(&report).unwrap();
547 assert!(!json.contains("file_scores"));
549 assert!(!json.contains("hotspots"));
550 assert!(!json.contains("hotspot_summary"));
551 assert!(!json.contains("targets"));
552 assert!(!json.contains("vital_signs"));
553 }
554
555 #[test]
556 fn vital_signs_serialization_roundtrip() {
557 let vs = VitalSigns {
558 dead_file_pct: Some(3.2),
559 dead_export_pct: Some(8.1),
560 avg_cyclomatic: 4.7,
561 p90_cyclomatic: 12,
562 duplication_pct: None,
563 hotspot_count: Some(5),
564 maintainability_avg: Some(72.4),
565 unused_dep_count: Some(4),
566 circular_dep_count: Some(2),
567 };
568 let json = serde_json::to_string(&vs).unwrap();
569 let deserialized: VitalSigns = serde_json::from_str(&json).unwrap();
570 assert_eq!(deserialized.avg_cyclomatic, 4.7);
571 assert_eq!(deserialized.p90_cyclomatic, 12);
572 assert_eq!(deserialized.hotspot_count, Some(5));
573 assert!(!json.contains("duplication_pct"));
575 assert!(deserialized.duplication_pct.is_none());
576 }
577
578 #[test]
579 fn vital_signs_snapshot_roundtrip() {
580 let snapshot = VitalSignsSnapshot {
581 snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION,
582 version: "1.8.1".into(),
583 timestamp: "2026-03-25T14:30:00Z".into(),
584 git_sha: Some("abc1234".into()),
585 git_branch: Some("main".into()),
586 shallow_clone: false,
587 vital_signs: VitalSigns {
588 dead_file_pct: Some(3.2),
589 dead_export_pct: Some(8.1),
590 avg_cyclomatic: 4.7,
591 p90_cyclomatic: 12,
592 duplication_pct: None,
593 hotspot_count: None,
594 maintainability_avg: Some(72.4),
595 unused_dep_count: Some(4),
596 circular_dep_count: Some(2),
597 },
598 counts: VitalSignsCounts {
599 total_files: 1200,
600 total_exports: 5400,
601 dead_files: 38,
602 dead_exports: 437,
603 duplicated_lines: None,
604 total_lines: None,
605 files_scored: Some(1150),
606 total_deps: 42,
607 },
608 };
609 let json = serde_json::to_string_pretty(&snapshot).unwrap();
610 let rt: VitalSignsSnapshot = serde_json::from_str(&json).unwrap();
611 assert_eq!(rt.snapshot_schema_version, SNAPSHOT_SCHEMA_VERSION);
612 assert_eq!(rt.git_sha.as_deref(), Some("abc1234"));
613 assert_eq!(rt.counts.total_files, 1200);
614 assert_eq!(rt.counts.dead_exports, 437);
615 }
616
617 #[test]
618 fn refactoring_target_skips_empty_factors() {
619 let target = RefactoringTarget {
620 path: std::path::PathBuf::from("/src/foo.ts"),
621 priority: 75.0,
622 efficiency: 75.0,
623 recommendation: "Test recommendation".into(),
624 category: RecommendationCategory::RemoveDeadCode,
625 effort: EffortEstimate::Low,
626 confidence: Confidence::High,
627 factors: vec![],
628 evidence: None,
629 };
630 let json = serde_json::to_string(&target).unwrap();
631 assert!(!json.contains("factors"));
632 assert!(!json.contains("evidence"));
633 }
634
635 #[test]
636 fn effort_numeric_values() {
637 assert!((EffortEstimate::Low.numeric() - 1.0).abs() < f64::EPSILON);
638 assert!((EffortEstimate::Medium.numeric() - 2.0).abs() < f64::EPSILON);
639 assert!((EffortEstimate::High.numeric() - 3.0).abs() < f64::EPSILON);
640 }
641
642 #[test]
643 fn confidence_labels_are_non_empty() {
644 let levels = [Confidence::High, Confidence::Medium, Confidence::Low];
645 for level in &levels {
646 assert!(!level.label().is_empty(), "{level:?} should have a label");
647 }
648 }
649
650 #[test]
651 fn confidence_serializes_as_snake_case() {
652 let json = serde_json::to_string(&Confidence::High).unwrap();
653 assert_eq!(json, r#""high""#);
654 let json = serde_json::to_string(&Confidence::Medium).unwrap();
655 assert_eq!(json, r#""medium""#);
656 let json = serde_json::to_string(&Confidence::Low).unwrap();
657 assert_eq!(json, r#""low""#);
658 }
659
660 #[test]
661 fn contributing_factor_serializes_correctly() {
662 let factor = ContributingFactor {
663 metric: "fan_in",
664 value: 15.0,
665 threshold: 10.0,
666 detail: "15 files depend on this".into(),
667 };
668 let json = serde_json::to_string(&factor).unwrap();
669 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
670 assert_eq!(parsed["metric"], "fan_in");
671 assert_eq!(parsed["value"], 15.0);
672 assert_eq!(parsed["threshold"], 10.0);
673 }
674}