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}
30
31#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
37pub struct VitalSigns {
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub dead_file_pct: Option<f64>,
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub dead_export_pct: Option<f64>,
44 pub avg_cyclomatic: f64,
46 pub p90_cyclomatic: u32,
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub duplication_pct: Option<f64>,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub hotspot_count: Option<u32>,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub maintainability_avg: Option<f64>,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub unused_dep_count: Option<u32>,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub circular_dep_count: Option<u32>,
63}
64
65#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
70pub struct VitalSignsCounts {
71 pub total_files: usize,
72 pub total_exports: usize,
73 pub dead_files: usize,
74 pub dead_exports: usize,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub duplicated_lines: Option<usize>,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub total_lines: Option<usize>,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub files_scored: Option<usize>,
81 pub total_deps: usize,
82}
83
84#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
86pub struct VitalSignsSnapshot {
87 pub snapshot_schema_version: u32,
89 pub version: String,
91 pub timestamp: String,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub git_sha: Option<String>,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub git_branch: Option<String>,
99 #[serde(default)]
101 pub shallow_clone: bool,
102 pub vital_signs: VitalSigns,
104 pub counts: VitalSignsCounts,
106}
107
108pub const SNAPSHOT_SCHEMA_VERSION: u32 = 1;
110
111pub const HOTSPOT_SCORE_THRESHOLD: f64 = 50.0;
113
114#[derive(Debug, serde::Serialize)]
116pub struct HealthFinding {
117 pub path: std::path::PathBuf,
119 pub name: String,
121 pub line: u32,
123 pub col: u32,
125 pub cyclomatic: u16,
127 pub cognitive: u16,
129 pub line_count: u32,
131 pub exceeded: ExceededThreshold,
133}
134
135#[derive(Debug, serde::Serialize)]
137#[serde(rename_all = "snake_case")]
138pub enum ExceededThreshold {
139 Cyclomatic,
141 Cognitive,
143 Both,
145}
146
147#[derive(Debug, serde::Serialize)]
149pub struct HealthSummary {
150 pub files_analyzed: usize,
152 pub functions_analyzed: usize,
154 pub functions_above_threshold: usize,
156 pub max_cyclomatic_threshold: u16,
158 pub max_cognitive_threshold: u16,
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub files_scored: Option<usize>,
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub average_maintainability: Option<f64>,
166}
167
168#[derive(Debug, Clone, serde::Serialize)]
188pub struct FileHealthScore {
189 pub path: std::path::PathBuf,
191 pub fan_in: usize,
193 pub fan_out: usize,
195 pub dead_code_ratio: f64,
199 pub complexity_density: f64,
201 pub maintainability_index: f64,
203 pub total_cyclomatic: u32,
205 pub total_cognitive: u32,
207 pub function_count: usize,
209 pub lines: u32,
211}
212
213#[derive(Debug, Clone, serde::Serialize)]
226pub struct HotspotEntry {
227 pub path: std::path::PathBuf,
229 pub score: f64,
231 pub commits: u32,
233 pub weighted_commits: f64,
235 pub lines_added: u32,
237 pub lines_deleted: u32,
239 pub complexity_density: f64,
241 pub fan_in: usize,
243 pub trend: fallow_core::churn::ChurnTrend,
245}
246
247#[derive(Debug, serde::Serialize)]
249pub struct HotspotSummary {
250 pub since: String,
252 pub min_commits: u32,
254 pub files_analyzed: usize,
256 pub files_excluded: usize,
258 pub shallow_clone: bool,
260}
261
262#[derive(Debug, Clone, serde::Serialize)]
264#[serde(rename_all = "snake_case")]
265pub enum RecommendationCategory {
266 UrgentChurnComplexity,
268 BreakCircularDependency,
270 SplitHighImpact,
272 RemoveDeadCode,
274 ExtractComplexFunctions,
276 ExtractDependencies,
278}
279
280impl RecommendationCategory {
281 pub fn label(&self) -> &'static str {
283 match self {
284 Self::UrgentChurnComplexity => "churn+complexity",
285 Self::BreakCircularDependency => "circular dep",
286 Self::SplitHighImpact => "high impact",
287 Self::RemoveDeadCode => "dead code",
288 Self::ExtractComplexFunctions => "complexity",
289 Self::ExtractDependencies => "coupling",
290 }
291 }
292}
293
294#[derive(Debug, Clone, serde::Serialize)]
296pub struct ContributingFactor {
297 pub metric: &'static str,
299 pub value: f64,
301 pub threshold: f64,
303 pub detail: String,
305}
306
307#[derive(Debug, Clone, serde::Serialize)]
324#[serde(rename_all = "snake_case")]
325pub enum EffortEstimate {
326 Low,
328 Medium,
330 High,
332}
333
334impl EffortEstimate {
335 pub fn label(&self) -> &'static str {
337 match self {
338 Self::Low => "low",
339 Self::Medium => "medium",
340 Self::High => "high",
341 }
342 }
343}
344
345#[derive(Debug, Clone, serde::Serialize)]
350pub struct TargetEvidence {
351 #[serde(skip_serializing_if = "Vec::is_empty")]
353 pub unused_exports: Vec<String>,
354 #[serde(skip_serializing_if = "Vec::is_empty")]
356 pub complex_functions: Vec<EvidenceFunction>,
357 #[serde(skip_serializing_if = "Vec::is_empty")]
359 pub cycle_path: Vec<String>,
360}
361
362#[derive(Debug, Clone, serde::Serialize)]
364pub struct EvidenceFunction {
365 pub name: String,
367 pub line: u32,
369 pub cognitive: u16,
371}
372
373#[derive(Debug, Clone, serde::Serialize)]
374pub struct RefactoringTarget {
375 pub path: std::path::PathBuf,
377 pub priority: f64,
379 pub recommendation: String,
381 pub category: RecommendationCategory,
383 pub effort: EffortEstimate,
385 #[serde(skip_serializing_if = "Vec::is_empty")]
387 pub factors: Vec<ContributingFactor>,
388 #[serde(skip_serializing_if = "Option::is_none")]
390 pub evidence: Option<TargetEvidence>,
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
400 fn category_labels_are_non_empty() {
401 let categories = [
402 RecommendationCategory::UrgentChurnComplexity,
403 RecommendationCategory::BreakCircularDependency,
404 RecommendationCategory::SplitHighImpact,
405 RecommendationCategory::RemoveDeadCode,
406 RecommendationCategory::ExtractComplexFunctions,
407 RecommendationCategory::ExtractDependencies,
408 ];
409 for cat in &categories {
410 assert!(!cat.label().is_empty(), "{cat:?} should have a label");
411 }
412 }
413
414 #[test]
415 fn category_labels_are_unique() {
416 let categories = [
417 RecommendationCategory::UrgentChurnComplexity,
418 RecommendationCategory::BreakCircularDependency,
419 RecommendationCategory::SplitHighImpact,
420 RecommendationCategory::RemoveDeadCode,
421 RecommendationCategory::ExtractComplexFunctions,
422 RecommendationCategory::ExtractDependencies,
423 ];
424 let labels: Vec<&str> = categories.iter().map(|c| c.label()).collect();
425 let unique: std::collections::HashSet<&&str> = labels.iter().collect();
426 assert_eq!(labels.len(), unique.len(), "category labels must be unique");
427 }
428
429 #[test]
432 fn category_serializes_as_snake_case() {
433 let json = serde_json::to_string(&RecommendationCategory::UrgentChurnComplexity).unwrap();
434 assert_eq!(json, r#""urgent_churn_complexity""#);
435
436 let json = serde_json::to_string(&RecommendationCategory::BreakCircularDependency).unwrap();
437 assert_eq!(json, r#""break_circular_dependency""#);
438 }
439
440 #[test]
441 fn exceeded_threshold_serializes_as_snake_case() {
442 let json = serde_json::to_string(&ExceededThreshold::Both).unwrap();
443 assert_eq!(json, r#""both""#);
444
445 let json = serde_json::to_string(&ExceededThreshold::Cyclomatic).unwrap();
446 assert_eq!(json, r#""cyclomatic""#);
447 }
448
449 #[test]
450 fn health_report_skips_empty_collections() {
451 let report = HealthReport {
452 findings: vec![],
453 summary: HealthSummary {
454 files_analyzed: 0,
455 functions_analyzed: 0,
456 functions_above_threshold: 0,
457 max_cyclomatic_threshold: 20,
458 max_cognitive_threshold: 15,
459 files_scored: None,
460 average_maintainability: None,
461 },
462 vital_signs: None,
463 file_scores: vec![],
464 hotspots: vec![],
465 hotspot_summary: None,
466 targets: vec![],
467 };
468 let json = serde_json::to_string(&report).unwrap();
469 assert!(!json.contains("file_scores"));
471 assert!(!json.contains("hotspots"));
472 assert!(!json.contains("hotspot_summary"));
473 assert!(!json.contains("targets"));
474 assert!(!json.contains("vital_signs"));
475 }
476
477 #[test]
478 fn vital_signs_serialization_roundtrip() {
479 let vs = VitalSigns {
480 dead_file_pct: Some(3.2),
481 dead_export_pct: Some(8.1),
482 avg_cyclomatic: 4.7,
483 p90_cyclomatic: 12,
484 duplication_pct: None,
485 hotspot_count: Some(5),
486 maintainability_avg: Some(72.4),
487 unused_dep_count: Some(4),
488 circular_dep_count: Some(2),
489 };
490 let json = serde_json::to_string(&vs).unwrap();
491 let deserialized: VitalSigns = serde_json::from_str(&json).unwrap();
492 assert_eq!(deserialized.avg_cyclomatic, 4.7);
493 assert_eq!(deserialized.p90_cyclomatic, 12);
494 assert_eq!(deserialized.hotspot_count, Some(5));
495 assert!(!json.contains("duplication_pct"));
497 assert!(deserialized.duplication_pct.is_none());
498 }
499
500 #[test]
501 fn vital_signs_snapshot_roundtrip() {
502 let snapshot = VitalSignsSnapshot {
503 snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION,
504 version: "1.8.1".into(),
505 timestamp: "2026-03-25T14:30:00Z".into(),
506 git_sha: Some("abc1234".into()),
507 git_branch: Some("main".into()),
508 shallow_clone: false,
509 vital_signs: VitalSigns {
510 dead_file_pct: Some(3.2),
511 dead_export_pct: Some(8.1),
512 avg_cyclomatic: 4.7,
513 p90_cyclomatic: 12,
514 duplication_pct: None,
515 hotspot_count: None,
516 maintainability_avg: Some(72.4),
517 unused_dep_count: Some(4),
518 circular_dep_count: Some(2),
519 },
520 counts: VitalSignsCounts {
521 total_files: 1200,
522 total_exports: 5400,
523 dead_files: 38,
524 dead_exports: 437,
525 duplicated_lines: None,
526 total_lines: None,
527 files_scored: Some(1150),
528 total_deps: 42,
529 },
530 };
531 let json = serde_json::to_string_pretty(&snapshot).unwrap();
532 let rt: VitalSignsSnapshot = serde_json::from_str(&json).unwrap();
533 assert_eq!(rt.snapshot_schema_version, SNAPSHOT_SCHEMA_VERSION);
534 assert_eq!(rt.git_sha.as_deref(), Some("abc1234"));
535 assert_eq!(rt.counts.total_files, 1200);
536 assert_eq!(rt.counts.dead_exports, 437);
537 }
538
539 #[test]
540 fn refactoring_target_skips_empty_factors() {
541 let target = RefactoringTarget {
542 path: std::path::PathBuf::from("/src/foo.ts"),
543 priority: 75.0,
544 recommendation: "Test recommendation".into(),
545 category: RecommendationCategory::RemoveDeadCode,
546 effort: EffortEstimate::Low,
547 factors: vec![],
548 evidence: None,
549 };
550 let json = serde_json::to_string(&target).unwrap();
551 assert!(!json.contains("factors"));
552 assert!(!json.contains("evidence"));
553 }
554
555 #[test]
556 fn contributing_factor_serializes_correctly() {
557 let factor = ContributingFactor {
558 metric: "fan_in",
559 value: 15.0,
560 threshold: 10.0,
561 detail: "15 files depend on this".into(),
562 };
563 let json = serde_json::to_string(&factor).unwrap();
564 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
565 assert_eq!(parsed["metric"], "fan_in");
566 assert_eq!(parsed["value"], 15.0);
567 assert_eq!(parsed["threshold"], 10.0);
568 }
569}