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