Skip to main content

retro_core/
config.rs

1use crate::errors::CoreError;
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct Config {
7    #[serde(default = "default_analysis")]
8    pub analysis: AnalysisConfig,
9    #[serde(default = "default_ai")]
10    pub ai: AiConfig,
11    #[serde(default = "default_hooks")]
12    pub hooks: HooksConfig,
13    #[serde(default = "default_paths")]
14    pub paths: PathsConfig,
15    #[serde(default = "default_privacy")]
16    pub privacy: PrivacyConfig,
17    #[serde(default = "default_claude_md")]
18    pub claude_md: ClaudeMdConfig,
19}
20
21impl Default for Config {
22    fn default() -> Self {
23        Self {
24            analysis: default_analysis(),
25            ai: default_ai(),
26            hooks: default_hooks(),
27            paths: default_paths(),
28            privacy: default_privacy(),
29            claude_md: default_claude_md(),
30        }
31    }
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct AnalysisConfig {
36    #[serde(default = "default_window_days")]
37    pub window_days: u32,
38    #[serde(default = "default_confidence_threshold")]
39    pub confidence_threshold: f64,
40    #[serde(default = "default_staleness_days")]
41    pub staleness_days: u32,
42    #[serde(default = "default_rolling_window")]
43    pub rolling_window: bool,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct AiConfig {
48    #[serde(default = "default_backend")]
49    pub backend: String,
50    #[serde(default = "default_model")]
51    pub model: String,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct HooksConfig {
56    #[serde(default = "default_ingest_cooldown")]
57    pub ingest_cooldown_minutes: u32,
58    #[serde(default = "default_analyze_cooldown")]
59    pub analyze_cooldown_minutes: u32,
60    #[serde(default = "default_apply_cooldown")]
61    pub apply_cooldown_minutes: u32,
62    #[serde(default = "default_auto_apply")]
63    pub auto_apply: bool,
64    #[serde(default = "default_post_commit")]
65    pub post_commit: String,
66    #[serde(default = "default_post_merge")]
67    pub post_merge: String,
68    #[serde(default = "default_auto_analyze_max_sessions")]
69    pub auto_analyze_max_sessions: u32,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct PathsConfig {
74    #[serde(default = "default_claude_dir")]
75    pub claude_dir: String,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct PrivacyConfig {
80    #[serde(default = "default_scrub_secrets")]
81    pub scrub_secrets: bool,
82    #[serde(default)]
83    pub exclude_projects: Vec<String>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct ClaudeMdConfig {
88    #[serde(default = "default_full_management")]
89    pub full_management: bool,
90}
91
92fn default_analysis() -> AnalysisConfig {
93    AnalysisConfig {
94        window_days: default_window_days(),
95        confidence_threshold: default_confidence_threshold(),
96        staleness_days: default_staleness_days(),
97        rolling_window: default_rolling_window(),
98    }
99}
100
101fn default_ai() -> AiConfig {
102    AiConfig {
103        backend: default_backend(),
104        model: default_model(),
105    }
106}
107
108fn default_hooks() -> HooksConfig {
109    HooksConfig {
110        ingest_cooldown_minutes: default_ingest_cooldown(),
111        analyze_cooldown_minutes: default_analyze_cooldown(),
112        apply_cooldown_minutes: default_apply_cooldown(),
113        auto_apply: default_auto_apply(),
114        post_commit: default_post_commit(),
115        post_merge: default_post_merge(),
116        auto_analyze_max_sessions: default_auto_analyze_max_sessions(),
117    }
118}
119
120fn default_paths() -> PathsConfig {
121    PathsConfig {
122        claude_dir: default_claude_dir(),
123    }
124}
125
126fn default_privacy() -> PrivacyConfig {
127    PrivacyConfig {
128        scrub_secrets: default_scrub_secrets(),
129        exclude_projects: Vec::new(),
130    }
131}
132
133fn default_window_days() -> u32 {
134    14
135}
136fn default_rolling_window() -> bool {
137    true
138}
139fn default_confidence_threshold() -> f64 {
140    0.7
141}
142fn default_staleness_days() -> u32 {
143    28
144}
145fn default_backend() -> String {
146    "claude-cli".to_string()
147}
148fn default_model() -> String {
149    "sonnet".to_string()
150}
151fn default_ingest_cooldown() -> u32 {
152    5
153}
154fn default_analyze_cooldown() -> u32 {
155    1440
156}
157fn default_apply_cooldown() -> u32 {
158    1440
159}
160fn default_auto_apply() -> bool {
161    true
162}
163fn default_post_commit() -> String {
164    "ingest".to_string()
165}
166fn default_post_merge() -> String {
167    "analyze".to_string()
168}
169fn default_auto_analyze_max_sessions() -> u32 {
170    15
171}
172fn default_claude_dir() -> String {
173    "~/.claude".to_string()
174}
175fn default_scrub_secrets() -> bool {
176    true
177}
178
179fn default_claude_md() -> ClaudeMdConfig {
180    ClaudeMdConfig {
181        full_management: default_full_management(),
182    }
183}
184
185fn default_full_management() -> bool {
186    false
187}
188
189impl Config {
190    /// Load config from the given path, or return defaults if file doesn't exist.
191    pub fn load(path: &Path) -> Result<Self, CoreError> {
192        if path.exists() {
193            let contents = std::fs::read_to_string(path)
194                .map_err(|e| CoreError::Io(format!("reading config: {e}")))?;
195            let config: Config =
196                toml::from_str(&contents).map_err(|e| CoreError::Config(e.to_string()))?;
197
198            Ok(config)
199        } else {
200            Ok(Config::default())
201        }
202    }
203
204    /// Write config to the given path.
205    pub fn save(&self, path: &Path) -> Result<(), CoreError> {
206        let contents =
207            toml::to_string_pretty(self).map_err(|e| CoreError::Config(e.to_string()))?;
208        if let Some(parent) = path.parent() {
209            std::fs::create_dir_all(parent)
210                .map_err(|e| CoreError::Io(format!("creating config dir: {e}")))?;
211        }
212        std::fs::write(path, contents)
213            .map_err(|e| CoreError::Io(format!("writing config: {e}")))?;
214        Ok(())
215    }
216
217    /// Resolve the claude_dir path, expanding ~ to home directory.
218    pub fn claude_dir(&self) -> PathBuf {
219        expand_tilde(&self.paths.claude_dir)
220    }
221}
222
223/// Get the retro data directory (~/.retro/).
224pub fn retro_dir() -> PathBuf {
225    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
226    PathBuf::from(home).join(".retro")
227}
228
229/// Expand ~ at the start of a path.
230pub fn expand_tilde(path: &str) -> PathBuf {
231    if let Some(rest) = path.strip_prefix("~/") {
232        let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
233        PathBuf::from(home).join(rest)
234    } else if path == "~" {
235        let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
236        PathBuf::from(home)
237    } else {
238        PathBuf::from(path)
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn test_hooks_config_defaults() {
248        let config = default_hooks();
249        assert_eq!(config.ingest_cooldown_minutes, 5);
250        assert_eq!(config.analyze_cooldown_minutes, 1440);
251        assert_eq!(config.apply_cooldown_minutes, 1440);
252        assert!(config.auto_apply);
253    }
254
255    #[test]
256    fn test_hooks_config_new_fields_deserialize() {
257        let toml_str = r#"
258[hooks]
259ingest_cooldown_minutes = 10
260analyze_cooldown_minutes = 720
261apply_cooldown_minutes = 2880
262auto_apply = false
263"#;
264        let config: Config = toml::from_str(toml_str).unwrap();
265        assert_eq!(config.hooks.ingest_cooldown_minutes, 10);
266        assert_eq!(config.hooks.analyze_cooldown_minutes, 720);
267        assert_eq!(config.hooks.apply_cooldown_minutes, 2880);
268        assert!(!config.hooks.auto_apply);
269    }
270
271    #[test]
272    fn test_hooks_config_partial_deserialize() {
273        // Config with only some fields should fill defaults for the rest
274        let toml_str = r#"
275[hooks]
276ingest_cooldown_minutes = 10
277auto_apply = false
278"#;
279        let config: Config = toml::from_str(toml_str).unwrap();
280        assert_eq!(config.hooks.ingest_cooldown_minutes, 10);
281        assert_eq!(config.hooks.analyze_cooldown_minutes, 1440); // default
282        assert_eq!(config.hooks.apply_cooldown_minutes, 1440); // default
283        assert!(!config.hooks.auto_apply);
284    }
285
286    #[test]
287    fn test_hooks_config_max_sessions_default() {
288        let config = Config::default();
289        assert_eq!(config.hooks.auto_analyze_max_sessions, 15);
290    }
291
292    #[test]
293    fn test_hooks_config_max_sessions_custom() {
294        let toml_str = r#"
295[hooks]
296auto_analyze_max_sessions = 5
297"#;
298        let config: Config = toml::from_str(toml_str).unwrap();
299        assert_eq!(config.hooks.auto_analyze_max_sessions, 5);
300    }
301
302    #[test]
303    fn test_claude_md_config_defaults() {
304        let config = Config::default();
305        assert!(!config.claude_md.full_management);
306    }
307
308    #[test]
309    fn test_claude_md_config_custom() {
310        let toml_str = r#"
311[claude_md]
312full_management = true
313"#;
314        let config: Config = toml::from_str(toml_str).unwrap();
315        assert!(config.claude_md.full_management);
316    }
317
318    #[test]
319    fn test_claude_md_config_absent() {
320        let toml_str = r#"
321[analysis]
322window_days = 7
323"#;
324        let config: Config = toml::from_str(toml_str).unwrap();
325        assert!(!config.claude_md.full_management);
326    }
327}