claude_code_toolkit/config/
manager.rs1use crate::error::{ ClaudeCodeError, Result };
4use crate::traits::{ ConfigManager as ConfigManagerTrait, ConfigProvider };
5use crate::types::Config;
6use async_trait::async_trait;
7use dirs::home_dir;
8use std::path::{ Path, PathBuf };
9use tokio::fs;
10use tracing::{ debug, info, warn };
11
12pub struct YamlConfigProvider {
14 config_path: PathBuf,
15 config_dir: PathBuf,
16}
17
18impl YamlConfigProvider {
19 pub fn new() -> Result<Self> {
20 let config_dir = home_dir()
21 .ok_or("Could not determine home directory")?
22 .join(".goodiebag")
23 .join("claude-code");
24
25 let config_path = config_dir.join("config.yml");
26
27 Ok(Self {
28 config_path,
29 config_dir,
30 })
31 }
32
33 pub fn with_path(config_path: PathBuf) -> Self {
34 let config_dir = config_path
35 .parent()
36 .map(|p| p.to_path_buf())
37 .unwrap_or_else(|| PathBuf::from("."));
38
39 Self {
40 config_path,
41 config_dir,
42 }
43 }
44
45 pub async fn ensure_config_dir(&self) -> Result<()> {
46 fs::create_dir_all(&self.config_dir).await.map_err(ClaudeCodeError::Io)?;
47 Ok(())
48 }
49}
50
51#[async_trait]
52impl ConfigProvider for YamlConfigProvider {
53 async fn load_config(&self) -> Result<Config> {
54 if !self.config_path.exists() {
55 debug!("Config file not found, returning default config");
56 return Ok(Config::default());
57 }
58
59 let content = fs::read_to_string(&self.config_path).await.map_err(ClaudeCodeError::Io)?;
60
61 let config: Config = serde_yaml
62 ::from_str(&content)
63 .map_err(|e| ClaudeCodeError::InvalidConfig(e.to_string()))?;
64
65 debug!("Loaded configuration from {:?}", self.config_path);
66 Ok(config)
67 }
68
69 async fn save_config(&self, config: &Config) -> Result<()> {
70 self.ensure_config_dir().await?;
71
72 let content = serde_yaml
73 ::to_string(config)
74 .map_err(|e| ClaudeCodeError::InvalidConfig(e.to_string()))?;
75
76 fs::write(&self.config_path, content).await.map_err(ClaudeCodeError::Io)?;
77
78 info!("Saved configuration to {:?}", self.config_path);
79 Ok(())
80 }
81
82 async fn validate_config(&self, config: &Config) -> Result<()> {
83 if config.daemon.log_level.is_empty() {
85 return Err(ClaudeCodeError::InvalidConfig("log_level cannot be empty".to_string()));
86 }
87
88 if config.daemon.sync_delay_after_expiry == 0 {
89 warn!("sync_delay_after_expiry is 0, which may cause rapid sync attempts");
90 }
91
92 for org in &config.github.organizations {
93 if org.name.is_empty() {
94 return Err(ClaudeCodeError::InvalidConfig("Organization name cannot be empty".to_string()));
95 }
96 }
97
98 for repo in &config.github.repositories {
99 if !repo.repo.contains('/') {
100 return Err(
101 ClaudeCodeError::InvalidConfig(format!("Invalid repository format: {}", repo.repo))
102 );
103 }
104 }
105
106 Ok(())
107 }
108
109 async fn config_exists(&self) -> Result<bool> {
110 Ok(self.config_path.exists())
111 }
112
113 fn config_path(&self) -> Option<&Path> {
114 Some(&self.config_path)
115 }
116
117 fn as_any(&self) -> &dyn std::any::Any {
118 self
119 }
120}
121
122pub struct ConfigurationManager {
124 provider: Box<dyn ConfigProvider>,
125 cache: Option<Config>,
126}
127
128impl ConfigurationManager {
129 pub fn new() -> Result<Self> {
130 let provider = Box::new(YamlConfigProvider::new()?);
131 Ok(Self {
132 provider,
133 cache: None,
134 })
135 }
136
137 pub fn with_provider(provider: Box<dyn ConfigProvider>) -> Self {
138 Self {
139 provider,
140 cache: None,
141 }
142 }
143
144 pub fn with_yaml_provider() -> Result<Self> {
145 Self::new()
146 }
147
148 pub fn invalidate_cache(&mut self) {
150 self.cache = None;
151 }
152
153 #[allow(dead_code)]
155 async fn get_cached_config(&mut self) -> Result<&Config> {
156 if self.cache.is_none() {
157 let config = self.provider.load_config().await?;
158 self.provider.validate_config(&config).await?;
159 self.cache = Some(config);
160 }
161
162 Ok(self.cache.as_ref().unwrap())
163 }
164}
165
166#[async_trait]
167impl ConfigManagerTrait for ConfigurationManager {
168 async fn initialize(&self) -> Result<Config> {
169 let config = Config::default();
170 self.provider.save_config(&config).await?;
171 info!("Initialized configuration with defaults");
172 Ok(config)
173 }
174
175 async fn load(&self) -> Result<Config> {
176 if !self.provider.config_exists().await? {
177 debug!("Config does not exist, initializing with defaults");
178 return self.initialize().await;
179 }
180
181 self.provider.load_config().await
182 }
183
184 async fn save(&self, config: &Config) -> Result<()> {
185 self.provider.validate_config(config).await?;
186 self.provider.save_config(config).await
187 }
188
189 async fn update_section<T>(&self, _section: &str, _data: T) -> Result<()> where T: Send + Sync {
190 todo!("Section updates not yet implemented")
193 }
194
195 async fn backup(&self) -> Result<String> {
196 let _config = self.provider.load_config().await?;
197 let timestamp = chrono::Utc::now().timestamp();
198 let backup_id = format!("backup_{}", timestamp);
199
200 info!("Created config backup: {}", backup_id);
203 Ok(backup_id)
204 }
205
206 async fn restore(&self, backup_id: &str) -> Result<()> {
207 info!("Restoring config from backup: {}", backup_id);
209 Ok(())
211 }
212}
213
214impl ConfigurationManager {
216 pub async fn load_config(&self) -> Result<Config> {
218 self.load().await
219 }
220
221 pub async fn save_config(&self, config: &Config) -> Result<()> {
223 self.save(config).await
224 }
225
226 pub async fn add_organization(&self, name: String) -> Result<()> {
228 let mut config = self.load_config().await?;
229
230 if config.github.organizations.iter().any(|org| org.name == name) {
232 return Err(ClaudeCodeError::Generic(format!("Organization '{}' already exists", name)));
233 }
234
235 config.github.organizations.push(crate::types::GitHubOrganization { name });
236
237 self.save_config(&config).await
238 }
239
240 pub async fn remove_organization(&self, name: &str) -> Result<()> {
242 let mut config = self.load_config().await?;
243
244 let original_len = config.github.organizations.len();
245 config.github.organizations.retain(|org| org.name != name);
246
247 if config.github.organizations.len() == original_len {
248 return Err(ClaudeCodeError::Generic(format!("Organization '{}' not found", name)));
249 }
250
251 self.save_config(&config).await
252 }
253
254 pub async fn add_repository(&self, repo: String) -> Result<()> {
256 let mut config = self.load_config().await?;
257
258 if config.github.repositories.iter().any(|r| r.repo == repo) {
260 return Err(ClaudeCodeError::Generic(format!("Repository '{}' already exists", repo)));
261 }
262
263 config.github.repositories.push(crate::types::GitHubRepository { repo });
264
265 self.save_config(&config).await
266 }
267
268 pub async fn remove_repository(&self, repo: &str) -> Result<()> {
270 let mut config = self.load_config().await?;
271
272 let original_len = config.github.repositories.len();
273 config.github.repositories.retain(|r| r.repo != repo);
274
275 if config.github.repositories.len() == original_len {
276 return Err(ClaudeCodeError::Generic(format!("Repository '{}' not found", repo)));
277 }
278
279 self.save_config(&config).await
280 }
281
282 pub async fn load_state(&self) -> Result<crate::types::SyncState> {
284 Ok(crate::types::SyncState {
287 last_sync: 0,
288 last_token: String::new(),
289 targets: vec![],
290 })
291 }
292
293 pub async fn ensure_config_dir(&self) -> Result<()> {
295 if let Some(yaml_provider) = self.provider.as_any().downcast_ref::<YamlConfigProvider>() {
296 yaml_provider.ensure_config_dir().await
297 } else {
298 Ok(())
299 }
300 }
301
302 pub fn config_path(&self) -> &Path {
304 self.provider.config_path().unwrap_or_else(|| Path::new(""))
305 }
306
307 pub fn config_dir(&self) -> &Path {
309 self
310 .config_path()
311 .parent()
312 .unwrap_or_else(|| Path::new(""))
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use crate::types::*;
320 use tempfile::TempDir;
321
322 fn create_test_config() -> Config {
323 Config {
324 daemon: DaemonConfig {
325 log_level: "info".to_string(),
326 sync_delay_after_expiry: 60,
327 },
328 github: GitHubConfig {
329 organizations: vec![GitHubOrganization {
330 name: "test-org".to_string(),
331 }],
332 repositories: vec![GitHubRepository {
333 repo: "owner/repo".to_string(),
334 }],
335 },
336 notifications: NotificationConfig {
337 session_warnings: vec![30, 15, 5],
338 sync_failures: true,
339 },
340 credentials: CredentialsConfig {
341 file_path: "~/.config/claude/test_credentials.json".to_string(),
342 json_path: "claudeAiOauth".to_string(),
343 field_mappings: {
344 let mut mappings = std::collections::HashMap::new();
345 mappings.insert("accessToken".to_string(), "CLAUDE_ACCESS_TOKEN".to_string());
346 mappings.insert("refreshToken".to_string(), "CLAUDE_REFRESH_TOKEN".to_string());
347 mappings.insert("expiresAt".to_string(), "CLAUDE_EXPIRES_AT".to_string());
348 mappings
349 },
350 },
351 }
352 }
353
354 #[tokio::test]
355 async fn test_yaml_provider_save_and_load() {
356 let temp_dir = TempDir::new().unwrap();
357 let config_path = temp_dir.path().join("test_config.yml");
358 let provider = YamlConfigProvider::with_path(config_path);
359
360 let test_config = create_test_config();
361
362 provider.save_config(&test_config).await.unwrap();
364
365 let loaded_config = provider.load_config().await.unwrap();
367
368 assert_eq!(loaded_config.daemon.log_level, test_config.daemon.log_level);
369 assert_eq!(loaded_config.github.organizations.len(), 1);
370 assert_eq!(loaded_config.github.organizations[0].name, "test-org");
371 }
372
373 #[tokio::test]
374 async fn test_yaml_provider_load_nonexistent() {
375 let temp_dir = TempDir::new().unwrap();
376 let config_path = temp_dir.path().join("nonexistent.yml");
377 let provider = YamlConfigProvider::with_path(config_path);
378
379 let config = provider.load_config().await.unwrap();
380
381 assert_eq!(config.daemon.log_level, "info");
383 assert!(config.github.organizations.is_empty());
384 }
385
386 #[tokio::test]
387 async fn test_config_validation() {
388 let temp_dir = TempDir::new().unwrap();
389 let config_path = temp_dir.path().join("test_config.yml");
390 let provider = YamlConfigProvider::with_path(config_path);
391
392 let valid_config = create_test_config();
394 assert!(provider.validate_config(&valid_config).await.is_ok());
395
396 let mut invalid_config = create_test_config();
398 invalid_config.daemon.log_level = "".to_string();
399 assert!(provider.validate_config(&invalid_config).await.is_err());
400
401 let mut invalid_config = create_test_config();
403 invalid_config.github.repositories[0].repo = "invalid-repo".to_string();
404 assert!(provider.validate_config(&invalid_config).await.is_err());
405 }
406
407 #[tokio::test]
408 async fn test_configuration_manager() {
409 let temp_dir = TempDir::new().unwrap();
410 let config_path = temp_dir.path().join("test_config.yml");
411 let provider = Box::new(YamlConfigProvider::with_path(config_path));
412 let manager = ConfigurationManager::with_provider(provider);
413
414 let config = manager.initialize().await.unwrap();
416 assert_eq!(config.daemon.log_level, "info");
417
418 let loaded_config = manager.load().await.unwrap();
420 assert_eq!(loaded_config.daemon.log_level, "info");
421
422 let mut test_config = create_test_config();
424 test_config.daemon.log_level = "debug".to_string();
425 manager.save(&test_config).await.unwrap();
426
427 let updated_config = manager.load().await.unwrap();
428 assert_eq!(updated_config.daemon.log_level, "debug");
429 }
430
431 #[tokio::test]
432 async fn test_backup_restore() {
433 let temp_dir = TempDir::new().unwrap();
434 let config_path = temp_dir.path().join("test_config.yml");
435 let provider = Box::new(YamlConfigProvider::with_path(config_path));
436 let manager = ConfigurationManager::with_provider(provider);
437
438 let config = create_test_config();
439 manager.save(&config).await.unwrap();
440
441 let backup_id = manager.backup().await.unwrap();
443 assert!(backup_id.starts_with("backup_"));
444
445 let result = manager.restore(&backup_id).await;
447 assert!(result.is_ok());
449 }
450}