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 { 7 }
56fn default_weekly() -> usize { 4 }
57fn default_monthly() -> usize { 12 }
58
59/// Threshold configuration for structural alerts
60///
61/// When metrics cross these thresholds, Pulse generates alerts in digests.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ThresholdConfig {
64    /// Fan-in warning threshold (default: 10)
65    #[serde(default = "default_fan_in_warning")]
66    pub fan_in_warning: usize,
67    /// Fan-in critical threshold (default: 25)
68    #[serde(default = "default_fan_in_critical")]
69    pub fan_in_critical: usize,
70    /// Minimum cycle length to flag (default: 3)
71    #[serde(default = "default_cycle_length")]
72    pub cycle_length: usize,
73    /// Module file count warning (default: 50)
74    #[serde(default = "default_module_file_count")]
75    pub module_file_count: usize,
76    /// Line count growth multiplier warning (default: 2.0)
77    #[serde(default = "default_line_count_growth")]
78    pub line_count_growth: f64,
79}
80
81impl Default for ThresholdConfig {
82    fn default() -> Self {
83        Self {
84            fan_in_warning: default_fan_in_warning(),
85            fan_in_critical: default_fan_in_critical(),
86            cycle_length: default_cycle_length(),
87            module_file_count: default_module_file_count(),
88            line_count_growth: default_line_count_growth(),
89        }
90    }
91}
92
93fn default_fan_in_warning() -> usize { 10 }
94fn default_fan_in_critical() -> usize { 25 }
95fn default_cycle_length() -> usize { 3 }
96fn default_module_file_count() -> usize { 50 }
97fn default_line_count_growth() -> f64 { 2.0 }
98
99/// Load Pulse configuration from the project's `.reflex/config.toml`
100///
101/// Falls back to defaults if the `[pulse]` section is missing.
102pub fn load_pulse_config(cache_path: &Path) -> Result<PulseConfig> {
103    let config_path = cache_path.join("config.toml");
104
105    if !config_path.exists() {
106        return Ok(PulseConfig::default());
107    }
108
109    let content = std::fs::read_to_string(&config_path)?;
110    let table: toml::Value = content.parse()?;
111
112    if let Some(pulse_section) = table.get("pulse") {
113        let config: PulseConfig = pulse_section.clone().try_into()?;
114        Ok(config)
115    } else {
116        Ok(PulseConfig::default())
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn test_default_config() {
126        let config = PulseConfig::default();
127        assert_eq!(config.retention.daily, 7);
128        assert_eq!(config.retention.weekly, 4);
129        assert_eq!(config.retention.monthly, 12);
130        assert_eq!(config.thresholds.fan_in_warning, 10);
131        assert_eq!(config.thresholds.fan_in_critical, 25);
132        assert_eq!(config.thresholds.cycle_length, 3);
133        assert_eq!(config.thresholds.module_file_count, 50);
134        assert!((config.thresholds.line_count_growth - 2.0).abs() < f64::EPSILON);
135    }
136
137    #[test]
138    fn test_load_missing_config() {
139        let config = load_pulse_config(Path::new("/nonexistent")).unwrap();
140        assert_eq!(config.retention.daily, 7);
141    }
142
143    #[test]
144    fn test_deserialize_partial_config() {
145        let toml_str = r#"
146            [pulse.retention]
147            daily = 14
148        "#;
149        let table: toml::Value = toml_str.parse().unwrap();
150        let pulse_section = table.get("pulse").unwrap();
151        let config: PulseConfig = pulse_section.clone().try_into().unwrap();
152        assert_eq!(config.retention.daily, 14);
153        assert_eq!(config.retention.weekly, 4); // default
154        assert_eq!(config.thresholds.fan_in_warning, 10); // default
155    }
156}