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}