use crate::CoverageModel;
pub const SNAPSHOT_SCHEMA_VERSION: u32 = 9;
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct VitalSigns {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dead_file_pct: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dead_export_pct: Option<f64>,
pub avg_cyclomatic: f64,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub critical_complexity_pct: Option<f64>,
pub p90_cyclomatic: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duplication_pct: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hotspot_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub hotspot_top_pct_count: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub maintainability_avg: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub maintainability_low_pct: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unused_dep_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub unused_deps_per_k_files: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub circular_dep_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub circular_deps_per_k_files: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub counts: Option<VitalSignsCounts>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub unit_size_profile: Option<RiskProfile>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub functions_over_60_loc_per_k: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub unit_interfacing_profile: Option<RiskProfile>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub p95_fan_in: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub coupling_high_pct: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub prop_drilling_chain_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub prop_drilling_max_depth: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub p95_render_fan_in: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub render_fan_in_high_pct: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub max_render_fan_in: Option<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub top_render_fan_in: Vec<RenderFanInTopComponent>,
#[serde(default)]
pub total_loc: u64,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct RenderFanInTopComponent {
pub component: String,
#[serde(serialize_with = "fallow_types::serde_path::serialize")]
pub path: std::path::PathBuf,
pub render_sites: u32,
pub distinct_parents: u32,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[allow(
clippy::struct_field_names,
reason = "risk suffix conveys that higher values are worse"
)]
pub struct RiskProfile {
pub low_risk: f64,
pub medium_risk: f64,
pub high_risk: f64,
pub very_high_risk: f64,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct VitalSignsCounts {
pub total_files: usize,
pub total_exports: usize,
pub dead_files: usize,
pub dead_exports: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duplicated_lines: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_lines: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub files_scored: Option<usize>,
pub total_deps: usize,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct VitalSignsSnapshot {
pub snapshot_schema_version: u32,
pub version: String,
pub timestamp: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_branch: Option<String>,
#[serde(default)]
pub shallow_clone: bool,
pub vital_signs: VitalSigns,
pub counts: VitalSignsCounts,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub score: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub grade: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub coverage_model: Option<CoverageModel>,
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
reason = "tests use unwrap to keep serialization assertions concise"
)]
mod tests {
use super::*;
#[test]
fn vital_signs_optional_fields_are_omitted() {
let vs = VitalSigns {
avg_cyclomatic: 5.0,
p90_cyclomatic: 10,
..VitalSigns::default()
};
let json = serde_json::to_string(&vs).unwrap();
assert!(json.contains("avg_cyclomatic"));
assert!(json.contains("p90_cyclomatic"));
assert!(!json.contains("dead_file_pct"));
assert!(!json.contains("top_render_fan_in"));
}
#[test]
fn snapshot_deserializes_old_shape_with_default_score_and_grade() {
let json = r#"{
"snapshot_schema_version": 1,
"version": "1.5.0",
"timestamp": "2025-01-01T00:00:00Z",
"shallow_clone": false,
"vital_signs": {
"avg_cyclomatic": 2.0,
"p90_cyclomatic": 5
},
"counts": {
"total_files": 100,
"total_exports": 500,
"dead_files": 0,
"dead_exports": 0,
"total_deps": 20
}
}"#;
let snap: VitalSignsSnapshot = serde_json::from_str(json).unwrap();
assert!(snap.score.is_none());
assert!(snap.grade.is_none());
assert_eq!(snap.snapshot_schema_version, 1);
}
}