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