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)]
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)]
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    /// Coverage model used for CRAP computation in this snapshot.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub coverage_model: Option<CoverageModel>,
38    /// Schema version of the compared snapshot.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub snapshot_schema_version: Option<u32>,
41}
42
43/// A single metric's trend between two snapshots.
44#[derive(Debug, Clone, serde::Serialize)]
45#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
46pub struct TrendMetric {
47    /// Metric identifier, e.g. `"score"` or `"dead_file_pct"`.
48    pub name: &'static str,
49    /// Human-readable label, e.g. `"Health Score"` or `"Dead Files"`.
50    pub label: &'static str,
51    /// Previous value (from snapshot).
52    pub previous: f64,
53    /// Current value (from this run).
54    pub current: f64,
55    /// Absolute change (current - previous).
56    pub delta: f64,
57    /// Direction of change.
58    pub direction: TrendDirection,
59    /// Unit for display, e.g. `"%"`, `""`, or `"pts"`.
60    pub unit: &'static str,
61    /// Raw count from previous snapshot (for JSON consumers).
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub previous_count: Option<TrendCount>,
64    /// Raw count from current run (for JSON consumers).
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub current_count: Option<TrendCount>,
67}
68
69/// Raw numerator/denominator for a percentage metric.
70#[derive(Debug, Clone, serde::Serialize)]
71#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
72pub struct TrendCount {
73    /// The numerator, e.g. dead files count.
74    pub value: usize,
75    /// The denominator, e.g. total files.
76    pub total: usize,
77}
78
79/// Direction of a metric's change, semantically (improving/declining/stable).
80#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
81#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
82#[serde(rename_all = "snake_case")]
83pub enum TrendDirection {
84    /// The metric moved in a beneficial direction.
85    Improving,
86    /// The metric moved in a detrimental direction.
87    Declining,
88    /// The metric stayed within tolerance.
89    Stable,
90}
91
92impl TrendDirection {
93    /// Arrow symbol for terminal output.
94    #[must_use]
95    pub const fn arrow(self) -> &'static str {
96        match self {
97            Self::Improving => "\u{2191}",
98            Self::Declining => "\u{2193}",
99            Self::Stable => "\u{2192}",
100        }
101    }
102
103    /// Human-readable label.
104    #[must_use]
105    pub const fn label(self) -> &'static str {
106        match self {
107            Self::Improving => "improving",
108            Self::Declining => "declining",
109            Self::Stable => "stable",
110        }
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn trend_direction_labels_are_stable() {
120        assert_eq!(TrendDirection::Improving.label(), "improving");
121        assert_eq!(TrendDirection::Declining.label(), "declining");
122        assert_eq!(TrendDirection::Stable.label(), "stable");
123    }
124
125    #[test]
126    fn trend_direction_serializes_as_snake_case() {
127        let value = serde_json::to_value(TrendDirection::Improving).expect("serialize trend");
128        assert_eq!(value, serde_json::json!("improving"));
129    }
130}