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    #[serde(default = "default_runner")]
20    pub runner: RunnerConfig,
21    #[serde(default = "default_trust")]
22    pub trust: TrustConfig,
23    #[serde(default = "default_knowledge")]
24    pub knowledge: KnowledgeConfig,
25}
26
27impl Default for Config {
28    fn default() -> Self {
29        Self {
30            analysis: default_analysis(),
31            ai: default_ai(),
32            hooks: default_hooks(),
33            paths: default_paths(),
34            privacy: default_privacy(),
35            claude_md: default_claude_md(),
36            runner: default_runner(),
37            trust: default_trust(),
38            knowledge: default_knowledge(),
39        }
40    }
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct AnalysisConfig {
45    #[serde(default = "default_window_days")]
46    pub window_days: u32,
47    #[serde(default = "default_confidence_threshold")]
48    pub confidence_threshold: f64,
49    #[serde(default = "default_staleness_days")]
50    pub staleness_days: u32,
51    #[serde(default = "default_rolling_window")]
52    pub rolling_window: bool,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct AiConfig {
57    #[serde(default = "default_backend")]
58    pub backend: String,
59    #[serde(default = "default_model")]
60    pub model: String,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct HooksConfig {
65    #[serde(default = "default_ingest_cooldown")]
66    pub ingest_cooldown_minutes: u32,
67    #[serde(default = "default_analyze_cooldown")]
68    pub analyze_cooldown_minutes: u32,
69    #[serde(default = "default_apply_cooldown")]
70    pub apply_cooldown_minutes: u32,
71    #[serde(default = "default_auto_apply")]
72    pub auto_apply: bool,
73    #[serde(default = "default_post_commit")]
74    pub post_commit: String,
75    #[serde(default = "default_post_merge")]
76    pub post_merge: String,
77    #[serde(default = "default_auto_analyze_max_sessions")]
78    pub auto_analyze_max_sessions: u32,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct PathsConfig {
83    #[serde(default = "default_claude_dir")]
84    pub claude_dir: String,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct PrivacyConfig {
89    #[serde(default = "default_scrub_secrets")]
90    pub scrub_secrets: bool,
91    #[serde(default)]
92    pub exclude_projects: Vec<String>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ClaudeMdConfig {
97    #[serde(default = "default_full_management")]
98    pub full_management: bool,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct RunnerConfig {
103    #[serde(default = "default_interval_seconds")]
104    pub interval_seconds: u32,
105    #[serde(default = "default_analysis_trigger")]
106    pub analysis_trigger: String,
107    #[serde(default = "default_analysis_threshold")]
108    pub analysis_threshold: u32,
109    #[serde(default)]
110    pub active_hours: Option<String>,
111    #[serde(default = "default_max_ai_calls_per_day")]
112    pub max_ai_calls_per_day: u32,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct TrustConfig {
117    #[serde(default = "default_trust_mode")]
118    pub mode: String,
119    #[serde(default = "default_auto_approve_config")]
120    pub auto_approve: AutoApproveConfig,
121    #[serde(default = "default_trust_scope_config")]
122    pub scope: TrustScopeConfig,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct AutoApproveConfig {
127    #[serde(default = "default_true")]
128    pub rules: bool,
129    #[serde(default)]
130    pub skills: bool,
131    #[serde(default = "default_true")]
132    pub preferences: bool,
133    #[serde(default = "default_true")]
134    pub directives: bool,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct TrustScopeConfig {
139    #[serde(default = "default_scope_review")]
140    pub global_changes: String,
141    #[serde(default = "default_scope_auto")]
142    pub project_changes: String,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct KnowledgeConfig {
147    #[serde(default = "default_confidence_threshold")]
148    pub confidence_threshold: f64,
149    #[serde(default = "default_global_promotion_threshold")]
150    pub global_promotion_threshold: f64,
151}
152
153fn default_analysis() -> AnalysisConfig {
154    AnalysisConfig {
155        window_days: default_window_days(),
156        confidence_threshold: default_confidence_threshold(),
157        staleness_days: default_staleness_days(),
158        rolling_window: default_rolling_window(),
159    }
160}
161
162fn default_ai() -> AiConfig {
163    AiConfig {
164        backend: default_backend(),
165        model: default_model(),
166    }
167}
168
169fn default_hooks() -> HooksConfig {
170    HooksConfig {
171        ingest_cooldown_minutes: default_ingest_cooldown(),
172        analyze_cooldown_minutes: default_analyze_cooldown(),
173        apply_cooldown_minutes: default_apply_cooldown(),
174        auto_apply: default_auto_apply(),
175        post_commit: default_post_commit(),
176        post_merge: default_post_merge(),
177        auto_analyze_max_sessions: default_auto_analyze_max_sessions(),
178    }
179}
180
181fn default_paths() -> PathsConfig {
182    PathsConfig {
183        claude_dir: default_claude_dir(),
184    }
185}
186
187fn default_privacy() -> PrivacyConfig {
188    PrivacyConfig {
189        scrub_secrets: default_scrub_secrets(),
190        exclude_projects: Vec::new(),
191    }
192}
193
194fn default_window_days() -> u32 {
195    14
196}
197fn default_rolling_window() -> bool {
198    true
199}
200fn default_confidence_threshold() -> f64 {
201    0.7
202}
203fn default_staleness_days() -> u32 {
204    28
205}
206fn default_backend() -> String {
207    "claude-cli".to_string()
208}
209fn default_model() -> String {
210    "sonnet".to_string()
211}
212fn default_ingest_cooldown() -> u32 {
213    5
214}
215fn default_analyze_cooldown() -> u32 {
216    1440
217}
218fn default_apply_cooldown() -> u32 {
219    1440
220}
221fn default_auto_apply() -> bool {
222    true
223}
224fn default_post_commit() -> String {
225    "ingest".to_string()
226}
227fn default_post_merge() -> String {
228    "analyze".to_string()
229}
230fn default_auto_analyze_max_sessions() -> u32 {
231    15
232}
233fn default_claude_dir() -> String {
234    "~/.claude".to_string()
235}
236fn default_scrub_secrets() -> bool {
237    true
238}
239
240fn default_claude_md() -> ClaudeMdConfig {
241    ClaudeMdConfig {
242        full_management: default_full_management(),
243    }
244}
245
246fn default_full_management() -> bool {
247    false
248}
249
250fn default_interval_seconds() -> u32 {
251    300
252}
253fn default_analysis_trigger() -> String {
254    "sessions".to_string()
255}
256fn default_analysis_threshold() -> u32 {
257    3
258}
259fn default_max_ai_calls_per_day() -> u32 {
260    10
261}
262fn default_trust_mode() -> String {
263    "review".to_string()
264}
265fn default_true() -> bool {
266    true
267}
268fn default_scope_review() -> String {
269    "review".to_string()
270}
271fn default_scope_auto() -> String {
272    "auto".to_string()
273}
274fn default_global_promotion_threshold() -> f64 {
275    0.85
276}
277
278fn default_runner() -> RunnerConfig {
279    RunnerConfig {
280        interval_seconds: default_interval_seconds(),
281        analysis_trigger: default_analysis_trigger(),
282        analysis_threshold: default_analysis_threshold(),
283        active_hours: None,
284        max_ai_calls_per_day: default_max_ai_calls_per_day(),
285    }
286}
287
288fn default_trust() -> TrustConfig {
289    TrustConfig {
290        mode: default_trust_mode(),
291        auto_approve: default_auto_approve_config(),
292        scope: default_trust_scope_config(),
293    }
294}
295
296fn default_auto_approve_config() -> AutoApproveConfig {
297    AutoApproveConfig {
298        rules: true,
299        skills: false,
300        preferences: true,
301        directives: true,
302    }
303}
304
305fn default_trust_scope_config() -> TrustScopeConfig {
306    TrustScopeConfig {
307        global_changes: default_scope_review(),
308        project_changes: default_scope_auto(),
309    }
310}
311
312fn default_knowledge() -> KnowledgeConfig {
313    KnowledgeConfig {
314        confidence_threshold: default_confidence_threshold(),
315        global_promotion_threshold: default_global_promotion_threshold(),
316    }
317}
318
319impl Config {
320    /// Load config from the given path, or return defaults if file doesn't exist.
321    pub fn load(path: &Path) -> Result<Self, CoreError> {
322        if path.exists() {
323            let contents = std::fs::read_to_string(path)
324                .map_err(|e| CoreError::Io(format!("reading config: {e}")))?;
325            let config: Config =
326                toml::from_str(&contents).map_err(|e| CoreError::Config(e.to_string()))?;
327
328            Ok(config)
329        } else {
330            Ok(Config::default())
331        }
332    }
333
334    /// Write config to the given path.
335    pub fn save(&self, path: &Path) -> Result<(), CoreError> {
336        let contents =
337            toml::to_string_pretty(self).map_err(|e| CoreError::Config(e.to_string()))?;
338        if let Some(parent) = path.parent() {
339            std::fs::create_dir_all(parent)
340                .map_err(|e| CoreError::Io(format!("creating config dir: {e}")))?;
341        }
342        std::fs::write(path, contents)
343            .map_err(|e| CoreError::Io(format!("writing config: {e}")))?;
344        Ok(())
345    }
346
347    /// Resolve the claude_dir path, expanding ~ to home directory.
348    pub fn claude_dir(&self) -> PathBuf {
349        expand_tilde(&self.paths.claude_dir)
350    }
351}
352
353/// Get the retro data directory (~/.retro/).
354pub fn retro_dir() -> PathBuf {
355    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
356    PathBuf::from(home).join(".retro")
357}
358
359/// Expand ~ at the start of a path.
360pub fn expand_tilde(path: &str) -> PathBuf {
361    if let Some(rest) = path.strip_prefix("~/") {
362        let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
363        PathBuf::from(home).join(rest)
364    } else if path == "~" {
365        let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
366        PathBuf::from(home)
367    } else {
368        PathBuf::from(path)
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn test_hooks_config_defaults() {
378        let config = default_hooks();
379        assert_eq!(config.ingest_cooldown_minutes, 5);
380        assert_eq!(config.analyze_cooldown_minutes, 1440);
381        assert_eq!(config.apply_cooldown_minutes, 1440);
382        assert!(config.auto_apply);
383    }
384
385    #[test]
386    fn test_hooks_config_new_fields_deserialize() {
387        let toml_str = r#"
388[hooks]
389ingest_cooldown_minutes = 10
390analyze_cooldown_minutes = 720
391apply_cooldown_minutes = 2880
392auto_apply = false
393"#;
394        let config: Config = toml::from_str(toml_str).unwrap();
395        assert_eq!(config.hooks.ingest_cooldown_minutes, 10);
396        assert_eq!(config.hooks.analyze_cooldown_minutes, 720);
397        assert_eq!(config.hooks.apply_cooldown_minutes, 2880);
398        assert!(!config.hooks.auto_apply);
399    }
400
401    #[test]
402    fn test_hooks_config_partial_deserialize() {
403        // Config with only some fields should fill defaults for the rest
404        let toml_str = r#"
405[hooks]
406ingest_cooldown_minutes = 10
407auto_apply = false
408"#;
409        let config: Config = toml::from_str(toml_str).unwrap();
410        assert_eq!(config.hooks.ingest_cooldown_minutes, 10);
411        assert_eq!(config.hooks.analyze_cooldown_minutes, 1440); // default
412        assert_eq!(config.hooks.apply_cooldown_minutes, 1440); // default
413        assert!(!config.hooks.auto_apply);
414    }
415
416    #[test]
417    fn test_hooks_config_max_sessions_default() {
418        let config = Config::default();
419        assert_eq!(config.hooks.auto_analyze_max_sessions, 15);
420    }
421
422    #[test]
423    fn test_hooks_config_max_sessions_custom() {
424        let toml_str = r#"
425[hooks]
426auto_analyze_max_sessions = 5
427"#;
428        let config: Config = toml::from_str(toml_str).unwrap();
429        assert_eq!(config.hooks.auto_analyze_max_sessions, 5);
430    }
431
432    #[test]
433    fn test_claude_md_config_defaults() {
434        let config = Config::default();
435        assert!(!config.claude_md.full_management);
436    }
437
438    #[test]
439    fn test_claude_md_config_custom() {
440        let toml_str = r#"
441[claude_md]
442full_management = true
443"#;
444        let config: Config = toml::from_str(toml_str).unwrap();
445        assert!(config.claude_md.full_management);
446    }
447
448    #[test]
449    fn test_claude_md_config_absent() {
450        let toml_str = r#"
451[analysis]
452window_days = 7
453"#;
454        let config: Config = toml::from_str(toml_str).unwrap();
455        assert!(!config.claude_md.full_management);
456    }
457
458    #[test]
459    fn test_runner_config_defaults() {
460        let config = Config::default();
461        assert_eq!(config.runner.interval_seconds, 300);
462        assert_eq!(config.runner.analysis_trigger, "sessions");
463        assert_eq!(config.runner.analysis_threshold, 3);
464        assert!(config.runner.active_hours.is_none());
465        assert_eq!(config.runner.max_ai_calls_per_day, 10);
466    }
467
468    #[test]
469    fn test_trust_config_defaults() {
470        let config = Config::default();
471        assert_eq!(config.trust.mode, "review");
472        assert!(config.trust.auto_approve.rules);
473        assert!(!config.trust.auto_approve.skills);
474        assert!(config.trust.auto_approve.preferences);
475        assert!(config.trust.auto_approve.directives);
476        assert_eq!(config.trust.scope.global_changes, "review");
477        assert_eq!(config.trust.scope.project_changes, "auto");
478    }
479
480    #[test]
481    fn test_knowledge_config_defaults() {
482        let config = Config::default();
483        assert_eq!(config.knowledge.confidence_threshold, 0.7);
484        assert_eq!(config.knowledge.global_promotion_threshold, 0.85);
485    }
486
487    #[test]
488    fn test_v2_config_deserialize() {
489        let toml_str = r#"
490[runner]
491interval_seconds = 120
492max_ai_calls_per_day = 5
493
494[trust]
495mode = "auto"
496
497[trust.auto_approve]
498skills = true
499
500[trust.scope]
501global_changes = "auto"
502
503[knowledge]
504confidence_threshold = 0.8
505"#;
506        let config: Config = toml::from_str(toml_str).unwrap();
507        assert_eq!(config.runner.interval_seconds, 120);
508        assert_eq!(config.runner.max_ai_calls_per_day, 5);
509        assert_eq!(config.trust.mode, "auto");
510        assert!(config.trust.auto_approve.skills);
511        assert_eq!(config.trust.scope.global_changes, "auto");
512        assert_eq!(config.knowledge.confidence_threshold, 0.8);
513    }
514
515    #[test]
516    fn test_v1_config_still_loads() {
517        // A pure v1 config (no runner/trust/knowledge sections) should still parse
518        let toml_str = r#"
519[analysis]
520window_days = 7
521
522[hooks]
523ingest_cooldown_minutes = 10
524auto_apply = false
525"#;
526        let config: Config = toml::from_str(toml_str).unwrap();
527        assert_eq!(config.analysis.window_days, 7);
528        assert_eq!(config.hooks.ingest_cooldown_minutes, 10);
529        // v2 sections should have defaults
530        assert_eq!(config.runner.interval_seconds, 300);
531        assert_eq!(config.trust.mode, "review");
532        assert_eq!(config.knowledge.confidence_threshold, 0.7);
533    }
534}