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