Skip to main content

vibe_workspace/worktree/
config_manager.rs

1//! Configuration management integration for worktree system
2
3use anyhow::{Context, Result};
4use std::path::{Path, PathBuf};
5use tracing::{debug, warn};
6
7use crate::workspace::config::{RepositoryWorktreeConfig, WorkspaceConfig};
8use crate::worktree::config::WorktreeConfig;
9
10/// Configuration manager for worktree settings
11pub struct WorktreeConfigManager {
12    workspace_config_path: PathBuf,
13}
14
15impl WorktreeConfigManager {
16    pub fn new(workspace_config_path: PathBuf) -> Self {
17        Self {
18            workspace_config_path,
19        }
20    }
21
22    /// Load worktree configuration for a specific repository
23    pub async fn load_config_for_repo(&self, repo_path: &Path) -> Result<WorktreeConfig> {
24        // Try to load workspace configuration
25        let workspace_config = self.load_workspace_config().await?;
26
27        // Find the repository in the workspace config
28        let repo_name = repo_path
29            .file_name()
30            .and_then(|n| n.to_str())
31            .context("Invalid repository path")?;
32
33        // Get repository-specific config with fallback to global
34        let config = workspace_config.get_worktree_config_for_repo(repo_name);
35
36        debug!(
37            "Loaded worktree config for {}: base_dir={}, prefix={}",
38            repo_name,
39            config.base_dir.display(),
40            config.prefix
41        );
42
43        Ok(config)
44    }
45
46    /// Save worktree configuration changes
47    pub async fn save_worktree_config(
48        &self,
49        global_config: Option<WorktreeConfig>,
50        repo_configs: Vec<(String, RepositoryWorktreeConfig)>,
51    ) -> Result<()> {
52        let mut workspace_config = self.load_workspace_config().await?;
53
54        // Update global worktree config if provided
55        if let Some(global) = global_config {
56            workspace_config.worktree = global;
57        }
58
59        // Update repository-specific configs
60        for (repo_name, repo_config) in repo_configs {
61            if let Some(repo) = workspace_config
62                .repositories
63                .iter_mut()
64                .find(|r| r.name == repo_name)
65            {
66                repo.worktree_config = Some(repo_config);
67            } else {
68                warn!("Repository '{}' not found in workspace config", repo_name);
69            }
70        }
71
72        // Save the updated configuration
73        self.save_workspace_config(&workspace_config).await?;
74
75        Ok(())
76    }
77
78    /// Initialize worktree configuration for a new repository
79    pub async fn initialize_repo_config(
80        &self,
81        repo_name: &str,
82        repo_config: Option<RepositoryWorktreeConfig>,
83    ) -> Result<()> {
84        let mut workspace_config = self.load_workspace_config().await?;
85
86        // Find or create repository entry
87        if let Some(repo) = workspace_config
88            .repositories
89            .iter_mut()
90            .find(|r| r.name == repo_name)
91        {
92            repo.worktree_config = repo_config;
93        } else {
94            warn!(
95                "Repository '{}' not found for worktree initialization",
96                repo_name
97            );
98            return Ok(());
99        }
100
101        self.save_workspace_config(&workspace_config).await?;
102        Ok(())
103    }
104
105    /// Migrate old configuration format to new format
106    pub async fn migrate_legacy_config(&self) -> Result<bool> {
107        // Check if there's an old worktree configuration file
108        let legacy_config_path = self
109            .workspace_config_path
110            .parent()
111            .unwrap_or_else(|| Path::new("."))
112            .join("worktree-config.yaml");
113
114        if !legacy_config_path.exists() {
115            return Ok(false); // No legacy config to migrate
116        }
117
118        debug!("Found legacy worktree config, migrating...");
119
120        // Load legacy configuration
121        let legacy_content = tokio::fs::read_to_string(&legacy_config_path).await?;
122        let legacy_config: WorktreeConfig = serde_yaml::from_str(&legacy_content)?;
123
124        // Load current workspace config
125        let mut workspace_config = self.load_workspace_config().await?;
126
127        // Merge legacy config into workspace config
128        workspace_config.worktree = legacy_config;
129
130        // Save updated workspace config
131        self.save_workspace_config(&workspace_config).await?;
132
133        // Archive the legacy config file
134        let archived_path = legacy_config_path.with_extension("yaml.migrated");
135        tokio::fs::rename(&legacy_config_path, &archived_path).await?;
136
137        debug!("Migrated legacy worktree config and archived original");
138        Ok(true)
139    }
140
141    /// Validate configuration across all repositories
142    pub async fn validate_all_configs(&self) -> Result<Vec<ConfigValidationError>> {
143        let workspace_config = self.load_workspace_config().await?;
144        let mut errors = Vec::new();
145
146        // Validate global configuration
147        if let Err(error) = workspace_config.worktree.validate() {
148            errors.push(ConfigValidationError {
149                repository: None,
150                error: error,
151            });
152        }
153
154        // Validate repository-specific configurations
155        for repo in &workspace_config.repositories {
156            if let Some(repo_config) = &repo.worktree_config {
157                let effective_config = repo_config.merge_with_global(&workspace_config.worktree);
158                if let Err(error) = effective_config.validate() {
159                    errors.push(ConfigValidationError {
160                        repository: Some(repo.name.clone()),
161                        error,
162                    });
163                }
164            }
165        }
166
167        Ok(errors)
168    }
169
170    /// Get configuration summary for diagnostics
171    pub async fn get_config_summary(&self) -> Result<ConfigSummary> {
172        let workspace_config = self.load_workspace_config().await?;
173
174        // Apply environment variable overrides to the global config
175        let mut global_config = workspace_config.worktree.clone();
176
177        // Apply environment variable overrides
178        if let Ok(mode) = std::env::var("VIBE_WORKTREE_MODE") {
179            global_config.mode = match mode.to_lowercase().as_str() {
180                "global" => crate::worktree::config::WorktreeMode::Global,
181                "local" => crate::worktree::config::WorktreeMode::Local,
182                _ => global_config.mode, // Keep existing if invalid
183            };
184        }
185
186        if let Ok(base_dir) = std::env::var("VIBE_WORKTREE_BASE") {
187            global_config.base_dir = PathBuf::from(base_dir);
188        }
189
190        if let Ok(prefix) = std::env::var("VIBE_WORKTREE_PREFIX") {
191            global_config.prefix = prefix;
192        }
193
194        let repo_overrides = workspace_config
195            .repositories
196            .iter()
197            .filter(|r| r.worktree_config.is_some())
198            .map(|r| (r.name.clone(), r.worktree_config.as_ref().unwrap().clone()))
199            .collect();
200
201        // Calculate resolved base directory (pass None for repo_root since this is global config)
202        let resolved_base_dir = global_config.get_resolved_base_dir(None);
203
204        Ok(ConfigSummary {
205            global_config,
206            resolved_base_dir,
207            repo_overrides,
208            total_repositories: workspace_config.repositories.len(),
209            enabled_repositories: workspace_config
210                .repositories
211                .iter()
212                .filter(|r| workspace_config.is_worktree_enabled_for_repo(&r.name))
213                .count(),
214        })
215    }
216
217    // Private helper methods
218
219    async fn load_workspace_config(&self) -> Result<WorkspaceConfig> {
220        if !self.workspace_config_path.exists() {
221            // Create default configuration if it doesn't exist
222            let default_config = WorkspaceConfig::default();
223            self.save_workspace_config(&default_config).await?;
224            return Ok(default_config);
225        }
226
227        let content = tokio::fs::read_to_string(&self.workspace_config_path)
228            .await
229            .with_context(|| {
230                format!(
231                    "Failed to read config from {}",
232                    self.workspace_config_path.display()
233                )
234            })?;
235
236        let config: WorkspaceConfig = serde_yaml::from_str(&content)
237            .with_context(|| "Failed to parse workspace configuration")?;
238
239        Ok(config)
240    }
241
242    async fn save_workspace_config(&self, config: &WorkspaceConfig) -> Result<()> {
243        // Ensure parent directory exists
244        if let Some(parent) = self.workspace_config_path.parent() {
245            tokio::fs::create_dir_all(parent).await?;
246        }
247
248        // Serialize configuration
249        let content = serde_yaml::to_string(config)
250            .with_context(|| "Failed to serialize workspace configuration")?;
251
252        // Write to file
253        tokio::fs::write(&self.workspace_config_path, content)
254            .await
255            .with_context(|| {
256                format!(
257                    "Failed to write config to {}",
258                    self.workspace_config_path.display()
259                )
260            })?;
261
262        debug!(
263            "Saved workspace configuration to {}",
264            self.workspace_config_path.display()
265        );
266        Ok(())
267    }
268}
269
270#[derive(Debug)]
271pub struct ConfigValidationError {
272    pub repository: Option<String>,
273    pub error: String,
274}
275
276#[derive(Debug)]
277pub struct ConfigSummary {
278    pub global_config: WorktreeConfig,
279    pub resolved_base_dir: PathBuf,
280    pub repo_overrides: Vec<(String, RepositoryWorktreeConfig)>,
281    pub total_repositories: usize,
282    pub enabled_repositories: usize,
283}
284
285impl ConfigSummary {
286    /// Generate a human-readable summary
287    pub fn format_summary(&self) -> String {
288        let mut summary = String::new();
289
290        summary.push_str("Worktree Configuration Summary:\n");
291        summary.push_str(&format!("  Mode: {:?}\n", self.global_config.mode));
292        summary.push_str(&format!("  Global prefix: {}\n", self.global_config.prefix));
293        summary.push_str(&format!(
294            "  Base directory (configured): {}\n",
295            self.global_config.base_dir.display()
296        ));
297
298        // Show resolved path if different from configured path
299        if self.resolved_base_dir != self.global_config.base_dir {
300            summary.push_str(&format!(
301                "  Base directory (resolved): {}\n",
302                self.resolved_base_dir.display()
303            ));
304        }
305
306        summary.push_str(&format!(
307            "  Total repositories: {}\n",
308            self.total_repositories
309        ));
310        summary.push_str(&format!(
311            "  Enabled repositories: {}\n",
312            self.enabled_repositories
313        ));
314
315        if !self.repo_overrides.is_empty() {
316            summary.push_str(&format!(
317                "  Repository overrides: {}\n",
318                self.repo_overrides.len()
319            ));
320            for (repo_name, _) in &self.repo_overrides {
321                summary.push_str(&format!("    - {}\n", repo_name));
322            }
323        }
324
325        summary
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::workspace::config::{AppIntegrations, Repository, WorkspaceInfo};
333    use tempfile::TempDir;
334
335    #[tokio::test]
336    async fn test_config_manager_creation() {
337        let temp_dir = TempDir::new().unwrap();
338        let config_path = temp_dir.path().join("config.yaml");
339
340        let config_manager = WorktreeConfigManager::new(config_path);
341
342        // Should be able to create config manager with any path
343        assert!(!config_manager
344            .workspace_config_path
345            .to_string_lossy()
346            .is_empty());
347    }
348
349    #[tokio::test]
350    async fn test_config_loading_for_repo() {
351        let temp_dir = TempDir::new().unwrap();
352        let config_path = temp_dir.path().join("config.yaml");
353
354        // Create a minimal workspace config
355        let workspace_config = WorkspaceConfig {
356            workspace: WorkspaceInfo {
357                name: "test".to_string(),
358                root: temp_dir.path().to_path_buf(),
359                auto_discover: true,
360            },
361            repositories: vec![Repository {
362                name: "test-repo".to_string(),
363                path: temp_dir.path().join("test-repo"),
364                url: None,
365                branch: None,
366                apps: std::collections::HashMap::new(),
367                worktree_config: Some(RepositoryWorktreeConfig {
368                    mode: None,
369                    prefix: Some("custom-prefix/".to_string()),
370                    base_dir: Some(PathBuf::from("/custom/path")),
371                    cleanup: None,
372                    merge_detection: None,
373                    disabled: Some(false),
374                }),
375            }],
376            groups: Vec::new(),
377            apps: AppIntegrations {
378                github: None,
379                warp: None,
380                iterm2: None,
381                vscode: None,
382                wezterm: None,
383                cursor: None,
384                windsurf: None,
385            },
386            preferences: None,
387            claude_agents: None,
388            worktree: WorktreeConfig::default(),
389        };
390
391        // Save the config
392        workspace_config.save_to_file(&config_path).await.unwrap();
393
394        let config_manager = WorktreeConfigManager::new(config_path);
395        let repo_path = temp_dir.path().join("test-repo");
396
397        let config = config_manager
398            .load_config_for_repo(&repo_path)
399            .await
400            .unwrap();
401
402        // Should have repository-specific overrides
403        assert_eq!(config.prefix, "custom-prefix/");
404        assert_eq!(config.base_dir, PathBuf::from("/custom/path"));
405    }
406
407    #[tokio::test]
408    async fn test_config_validation() {
409        let temp_dir = TempDir::new().unwrap();
410        let config_path = temp_dir.path().join("config.yaml");
411
412        let config_manager = WorktreeConfigManager::new(config_path);
413
414        // Should return empty errors for default config
415        let errors = config_manager.validate_all_configs().await.unwrap();
416        assert!(errors.is_empty());
417    }
418
419    #[tokio::test]
420    async fn test_config_summary() {
421        let temp_dir = TempDir::new().unwrap();
422        let config_path = temp_dir.path().join("config.yaml");
423
424        let config_manager = WorktreeConfigManager::new(config_path);
425
426        let summary = config_manager.get_config_summary().await.unwrap();
427
428        // Should have basic summary information
429        assert!(!summary.global_config.prefix.is_empty());
430        assert_eq!(summary.total_repositories, 0);
431        assert_eq!(summary.enabled_repositories, 0);
432    }
433
434    #[test]
435    fn test_repository_config_merge() {
436        let global = WorktreeConfig::default();
437        let repo_config = RepositoryWorktreeConfig {
438            mode: None,
439            prefix: Some("custom-prefix/".to_string()),
440            base_dir: Some(PathBuf::from("/custom/path")),
441            cleanup: None,
442            merge_detection: None,
443            disabled: Some(false),
444        };
445
446        let merged = repo_config.merge_with_global(&global);
447
448        assert_eq!(merged.prefix, "custom-prefix/");
449        assert_eq!(merged.base_dir, PathBuf::from("/custom/path"));
450        // Other settings should come from global
451        assert_eq!(merged.auto_gitignore, global.auto_gitignore);
452        assert_eq!(merged.default_editor, global.default_editor);
453    }
454
455    #[test]
456    fn test_repository_config_enabled() {
457        let mut repo_config = RepositoryWorktreeConfig::default();
458        assert!(repo_config.is_enabled()); // Enabled by default
459
460        repo_config.disabled = Some(true);
461        assert!(!repo_config.is_enabled());
462
463        repo_config.disabled = Some(false);
464        assert!(repo_config.is_enabled());
465    }
466}