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 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 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 pub fn claude_dir(&self) -> PathBuf {
352 expand_tilde(&self.paths.claude_dir)
353 }
354}
355
356pub fn retro_dir() -> PathBuf {
358 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
359 PathBuf::from(home).join(".retro")
360}
361
362pub fn expand_tilde(path: &str) -> PathBuf {
364 if let Some(rest) = path.strip_prefix("~/") {
365 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
366 PathBuf::from(home).join(rest)
367 } else if path == "~" {
368 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
369 PathBuf::from(home)
370 } else {
371 PathBuf::from(path)
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378
379 #[test]
380 fn test_hooks_config_defaults() {
381 let config = default_hooks();
382 assert_eq!(config.ingest_cooldown_minutes, 5);
383 assert_eq!(config.analyze_cooldown_minutes, 1440);
384 assert_eq!(config.apply_cooldown_minutes, 1440);
385 assert!(config.auto_apply);
386 }
387
388 #[test]
389 fn test_hooks_config_new_fields_deserialize() {
390 let toml_str = r#"
391[hooks]
392ingest_cooldown_minutes = 10
393analyze_cooldown_minutes = 720
394apply_cooldown_minutes = 2880
395auto_apply = false
396"#;
397 let config: Config = toml::from_str(toml_str).unwrap();
398 assert_eq!(config.hooks.ingest_cooldown_minutes, 10);
399 assert_eq!(config.hooks.analyze_cooldown_minutes, 720);
400 assert_eq!(config.hooks.apply_cooldown_minutes, 2880);
401 assert!(!config.hooks.auto_apply);
402 }
403
404 #[test]
405 fn test_hooks_config_partial_deserialize() {
406 let toml_str = r#"
408[hooks]
409ingest_cooldown_minutes = 10
410auto_apply = false
411"#;
412 let config: Config = toml::from_str(toml_str).unwrap();
413 assert_eq!(config.hooks.ingest_cooldown_minutes, 10);
414 assert_eq!(config.hooks.analyze_cooldown_minutes, 1440); assert_eq!(config.hooks.apply_cooldown_minutes, 1440); assert!(!config.hooks.auto_apply);
417 }
418
419 #[test]
420 fn test_hooks_config_max_sessions_default() {
421 let config = Config::default();
422 assert_eq!(config.hooks.auto_analyze_max_sessions, 15);
423 }
424
425 #[test]
426 fn test_hooks_config_max_sessions_custom() {
427 let toml_str = r#"
428[hooks]
429auto_analyze_max_sessions = 5
430"#;
431 let config: Config = toml::from_str(toml_str).unwrap();
432 assert_eq!(config.hooks.auto_analyze_max_sessions, 5);
433 }
434
435 #[test]
436 fn test_claude_md_config_defaults() {
437 let config = Config::default();
438 assert!(!config.claude_md.full_management);
439 }
440
441 #[test]
442 fn test_claude_md_config_custom() {
443 let toml_str = r#"
444[claude_md]
445full_management = true
446"#;
447 let config: Config = toml::from_str(toml_str).unwrap();
448 assert!(config.claude_md.full_management);
449 }
450
451 #[test]
452 fn test_claude_md_config_absent() {
453 let toml_str = r#"
454[analysis]
455window_days = 7
456"#;
457 let config: Config = toml::from_str(toml_str).unwrap();
458 assert!(!config.claude_md.full_management);
459 }
460
461 #[test]
462 fn test_runner_config_defaults() {
463 let config = Config::default();
464 assert_eq!(config.runner.interval_seconds, 300);
465 assert_eq!(config.runner.analysis_trigger, "sessions");
466 assert_eq!(config.runner.analysis_threshold, 3);
467 assert!(config.runner.active_hours.is_none());
468 assert_eq!(config.runner.max_ai_calls_per_day, 10);
469 }
470
471 #[test]
472 fn test_trust_config_defaults() {
473 let config = Config::default();
474 assert_eq!(config.trust.mode, "review");
475 assert!(config.trust.auto_approve.rules);
476 assert!(!config.trust.auto_approve.skills);
477 assert!(config.trust.auto_approve.preferences);
478 assert!(config.trust.auto_approve.directives);
479 assert_eq!(config.trust.scope.global_changes, "review");
480 assert_eq!(config.trust.scope.project_changes, "auto");
481 }
482
483 #[test]
484 fn test_knowledge_config_defaults() {
485 let config = Config::default();
486 assert_eq!(config.knowledge.confidence_threshold, 0.7);
487 assert_eq!(config.knowledge.global_promotion_threshold, 0.85);
488 }
489
490 #[test]
491 fn test_v2_config_deserialize() {
492 let toml_str = r#"
493[runner]
494interval_seconds = 120
495max_ai_calls_per_day = 5
496
497[trust]
498mode = "auto"
499
500[trust.auto_approve]
501skills = true
502
503[trust.scope]
504global_changes = "auto"
505
506[knowledge]
507confidence_threshold = 0.8
508"#;
509 let config: Config = toml::from_str(toml_str).unwrap();
510 assert_eq!(config.runner.interval_seconds, 120);
511 assert_eq!(config.runner.max_ai_calls_per_day, 5);
512 assert_eq!(config.trust.mode, "auto");
513 assert!(config.trust.auto_approve.skills);
514 assert_eq!(config.trust.scope.global_changes, "auto");
515 assert_eq!(config.knowledge.confidence_threshold, 0.8);
516 }
517
518 #[test]
519 fn test_v1_config_still_loads() {
520 let toml_str = r#"
522[analysis]
523window_days = 7
524
525[hooks]
526ingest_cooldown_minutes = 10
527auto_apply = false
528"#;
529 let config: Config = toml::from_str(toml_str).unwrap();
530 assert_eq!(config.analysis.window_days, 7);
531 assert_eq!(config.hooks.ingest_cooldown_minutes, 10);
532 assert_eq!(config.runner.interval_seconds, 300);
534 assert_eq!(config.trust.mode, "review");
535 assert_eq!(config.knowledge.confidence_threshold, 0.7);
536 }
537}