Skip to main content

fallow_output/
health_trends.rs

1//! Trend types: comparing current run against a saved snapshot.
2
3use crate::CoverageModel;
4
5/// Trend comparison between the current run and a previous snapshot. Shows
6/// per-metric deltas with directional indicators.
7#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
8#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
9pub struct HealthTrend {
10    /// The snapshot being compared against.
11    pub compared_to: TrendPoint,
12    /// Per-metric deltas.
13    pub metrics: Vec<TrendMetric>,
14    /// Number of snapshots found in the snapshot directory.
15    pub snapshots_loaded: usize,
16    /// Overall direction across all metrics.
17    pub overall_direction: TrendDirection,
18}
19
20/// A reference to a snapshot used in trend comparison.
21#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
22#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
23pub struct TrendPoint {
24    /// ISO 8601 timestamp of the snapshot.
25    pub timestamp: String,
26    /// Git SHA at time of snapshot.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub git_sha: Option<String>,
29    /// Health score from the snapshot (stored, not re-derived).
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub score: Option<f64>,
32    /// Letter grade from the snapshot.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub grade: Option<String>,
35    /// Formula used for the stored score; absent on legacy snapshots.
36    /// A score delta is emitted only when this matches the current score formula.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub score_formula_version: Option<u32>,
39    /// Coverage model used for CRAP computation in this snapshot.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub coverage_model: Option<CoverageModel>,
42    /// Schema version of the compared snapshot.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub snapshot_schema_version: Option<u32>,
45}
46
47/// Explain why scores with unknown or differing formula identities cannot be
48/// compared. Uses identities from the saved report, independent of this binary.
49#[must_use]
50pub fn health_score_comparison_note(
51    previous_formula: Option<u32>,
52    current_formula: Option<u32>,
53) -> Option<&'static str> {
54    let (Some(previous), Some(current)) = (previous_formula, current_formula) else {
55        return Some("Score comparison omitted: score formula version is unknown.");
56    };
57    if previous != current {
58        return Some("Score comparison omitted: score formulas differ.");
59    }
60    None
61}
62
63/// A single metric's trend between two snapshots.
64#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
65#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
66pub struct TrendMetric {
67    /// Metric identifier, e.g. `"score"` or `"dead_file_pct"`.
68    #[serde(deserialize_with = "crate::static_str::deserialize")]
69    pub name: crate::static_str::StaticStr,
70    /// Human-readable label, e.g. `"Health Score"` or `"Dead Files"`.
71    #[serde(deserialize_with = "crate::static_str::deserialize")]
72    pub label: crate::static_str::StaticStr,
73    /// Previous value (from snapshot).
74    pub previous: f64,
75    /// Current value (from this run).
76    pub current: f64,
77    /// Absolute change (current - previous).
78    pub delta: f64,
79    /// Direction of change.
80    pub direction: TrendDirection,
81    /// Unit for display, e.g. `"%"`, `""`, or `"pts"`.
82    #[serde(deserialize_with = "crate::static_str::deserialize")]
83    pub unit: crate::static_str::StaticStr,
84    /// Raw count from previous snapshot (for JSON consumers).
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub previous_count: Option<TrendCount>,
87    /// Raw count from current run (for JSON consumers).
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub current_count: Option<TrendCount>,
90}
91
92/// Raw numerator/denominator for a percentage metric.
93#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
94#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
95pub struct TrendCount {
96    /// The numerator, e.g. dead files count.
97    pub value: usize,
98    /// The denominator, e.g. total files.
99    pub total: usize,
100}
101
102/// Direction of a metric's change, semantically (improving/declining/stable).
103#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
104#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
105#[serde(rename_all = "snake_case")]
106pub enum TrendDirection {
107    /// The metric moved in a beneficial direction.
108    Improving,
109    /// The metric moved in a detrimental direction.
110    Declining,
111    /// The metric stayed within tolerance.
112    Stable,
113}
114
115impl TrendDirection {
116    /// Arrow symbol for terminal output.
117    #[must_use]
118    pub const fn arrow(self) -> &'static str {
119        match self {
120            Self::Improving => "\u{2191}",
121            Self::Declining => "\u{2193}",
122            Self::Stable => "\u{2192}",
123        }
124    }
125
126    /// Human-readable label.
127    #[must_use]
128    pub const fn label(self) -> &'static str {
129        match self {
130            Self::Improving => "improving",
131            Self::Declining => "declining",
132            Self::Stable => "stable",
133        }
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn trend_direction_labels_are_stable() {
143        assert_eq!(TrendDirection::Improving.label(), "improving");
144        assert_eq!(TrendDirection::Declining.label(), "declining");
145        assert_eq!(TrendDirection::Stable.label(), "stable");
146    }
147
148    #[test]
149    fn trend_direction_serializes_as_snake_case() {
150        let value = serde_json::to_value(TrendDirection::Improving).expect("serialize trend");
151        assert_eq!(value, serde_json::json!("improving"));
152    }
153}