Skip to main content

reflex/pulse/
config.rs

1//! Pulse configuration types
2//!
3//! Configuration for snapshot retention, threshold alerts, and generation options.
4//! Settings are loaded from the `[pulse]` section of `.reflex/config.toml`.
5
6use anyhow::Result;
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9
10/// Top-level Pulse configuration
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct PulseConfig {
13    #[serde(default)]
14    pub retention: RetentionConfig,
15    #[serde(default)]
16    pub thresholds: ThresholdConfig,
17}
18
19impl Default for PulseConfig {
20    fn default() -> Self {
21        Self {
22            retention: RetentionConfig::default(),
23            thresholds: ThresholdConfig::default(),
24        }
25    }
26}
27
28/// Snapshot retention policy
29///
30/// Controls how many snapshots are kept at each granularity level.
31/// Under steady state with defaults: ~23 snapshots total.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct RetentionConfig {
34    /// Number of daily snapshots to keep (default: 7)
35    #[serde(default = "default_daily")]
36    pub daily: usize,
37    /// Number of weekly snapshots to keep (default: 4)
38    #[serde(default = "default_weekly")]
39    pub weekly: usize,
40    /// Number of monthly snapshots to keep (default: 12)
41    #[serde(default = "default_monthly")]
42    pub monthly: usize,
43}
44
45impl Default for RetentionConfig {
46    fn default() -> Self {
47        Self {
48            daily: default_daily(),
49            weekly: default_weekly(),
50            monthly: default_monthly(),
51        }
52    }
53}
54
55fn default_daily() -> usize {
56    7
57}
58fn default_weekly() -> usize {
59    4
60}
61fn default_monthly() -> usize {
62    12
63}
64
65/// Threshold configuration for structural alerts
66///
67/// When metrics cross these thresholds, Pulse generates alerts in digests.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct ThresholdConfig {
70    /// Fan-in warning threshold (default: 10)
71    #[serde(default = "default_fan_in_warning")]
72    pub fan_in_warning: usize,
73    /// Fan-in critical threshold (default: 25)
74    #[serde(default = "default_fan_in_critical")]
75    pub fan_in_critical: usize,
76    /// Minimum cycle length to flag (default: 3)
77    #[serde(default = "default_cycle_length")]
78    pub cycle_length: usize,
79    /// Module file count warning (default: 50)
80    #[serde(default = "default_module_file_count")]
81    pub module_file_count: usize,
82    /// Line count growth multiplier warning (default: 2.0)
83    #[serde(default = "default_line_count_growth")]
84    pub line_count_growth: f64,
85}
86
87impl Default for ThresholdConfig {
88    fn default() -> Self {
89        Self {
90            fan_in_warning: default_fan_in_warning(),
91            fan_in_critical: default_fan_in_critical(),
92            cycle_length: default_cycle_length(),
93            module_file_count: default_module_file_count(),
94            line_count_growth: default_line_count_growth(),
95        }
96    }
97}
98
99fn default_fan_in_warning() -> usize {
100    10
101}
102fn default_fan_in_critical() -> usize {
103    25
104}
105fn default_cycle_length() -> usize {
106    3
107}
108fn default_module_file_count() -> usize {
109    50
110}
111fn default_line_count_growth() -> f64 {
112    2.0
113}
114
115/// Load Pulse configuration from the project's `.reflex/config.toml`
116///
117/// Falls back to defaults if the `[pulse]` section is missing.
118pub fn load_pulse_config(cache_path: &Path) -> Result<PulseConfig> {
119    let config_path = cache_path.join("config.toml");
120
121    if !config_path.exists() {
122        return Ok(PulseConfig::default());
123    }
124
125    let content = std::fs::read_to_string(&config_path)?;
126    let table: toml::Value = content.parse()?;
127
128    if let Some(pulse_section) = table.get("pulse") {
129        let config: PulseConfig = pulse_section.clone().try_into()?;
130        Ok(config)
131    } else {
132        Ok(PulseConfig::default())
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn test_default_config() {
142        let config = PulseConfig::default();
143        assert_eq!(config.retention.daily, 7);
144        assert_eq!(config.retention.weekly, 4);
145        assert_eq!(config.retention.monthly, 12);
146        assert_eq!(config.thresholds.fan_in_warning, 10);
147        assert_eq!(config.thresholds.fan_in_critical, 25);
148        assert_eq!(config.thresholds.cycle_length, 3);
149        assert_eq!(config.thresholds.module_file_count, 50);
150        assert!((config.thresholds.line_count_growth - 2.0).abs() < f64::EPSILON);
151    }
152
153    #[test]
154    fn test_load_missing_config() {
155        let config = load_pulse_config(Path::new("/nonexistent")).unwrap();
156        assert_eq!(config.retention.daily, 7);
157    }
158
159    #[test]
160    fn test_deserialize_partial_config() {
161        let toml_str = r#"
162            [pulse.retention]
163            daily = 14
164        "#;
165        let table: toml::Value = toml_str.parse().unwrap();
166        let pulse_section = table.get("pulse").unwrap();
167        let config: PulseConfig = pulse_section.clone().try_into().unwrap();
168        assert_eq!(config.retention.daily, 14);
169        assert_eq!(config.retention.weekly, 4); // default
170        assert_eq!(config.thresholds.fan_in_warning, 10); // default
171    }
172}