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