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 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 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 pub fn claude_dir(&self) -> PathBuf {
200 expand_tilde(&self.paths.claude_dir)
201 }
202}
203
204pub fn retro_dir() -> PathBuf {
206 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
207 PathBuf::from(home).join(".retro")
208}
209
210pub 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 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); assert_eq!(config.hooks.apply_cooldown_minutes, 1440); 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}