1use 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
10pub 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 pub async fn load_config_for_repo(&self, repo_path: &Path) -> Result<WorktreeConfig> {
24 let workspace_config = self.load_workspace_config().await?;
26
27 let repo_name = repo_path
29 .file_name()
30 .and_then(|n| n.to_str())
31 .context("Invalid repository path")?;
32
33 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 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 if let Some(global) = global_config {
56 workspace_config.worktree = global;
57 }
58
59 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 self.save_workspace_config(&workspace_config).await?;
74
75 Ok(())
76 }
77
78 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 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 pub async fn migrate_legacy_config(&self) -> Result<bool> {
107 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); }
117
118 debug!("Found legacy worktree config, migrating...");
119
120 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 let mut workspace_config = self.load_workspace_config().await?;
126
127 workspace_config.worktree = legacy_config;
129
130 self.save_workspace_config(&workspace_config).await?;
132
133 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 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 if let Err(error) = workspace_config.worktree.validate() {
148 errors.push(ConfigValidationError {
149 repository: None,
150 error: error,
151 });
152 }
153
154 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 pub async fn get_config_summary(&self) -> Result<ConfigSummary> {
172 let workspace_config = self.load_workspace_config().await?;
173
174 let mut global_config = workspace_config.worktree.clone();
176
177 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, };
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 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 async fn load_workspace_config(&self) -> Result<WorkspaceConfig> {
220 if !self.workspace_config_path.exists() {
221 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 if let Some(parent) = self.workspace_config_path.parent() {
245 tokio::fs::create_dir_all(parent).await?;
246 }
247
248 let content = serde_yaml::to_string(config)
250 .with_context(|| "Failed to serialize workspace configuration")?;
251
252 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 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 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 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 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 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 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 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 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 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()); 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}