Skip to main content

vibe_workspace/worktree/
config.rs

1//! Configuration structures for worktree management
2
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5
6/// Worktree storage mode
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum WorktreeMode {
10    /// Store worktrees locally within each repository (default)
11    Local,
12    /// Store worktrees globally in a central location
13    Global,
14}
15
16impl Default for WorktreeMode {
17    fn default() -> Self {
18        Self::Local
19    }
20}
21
22/// Configuration for worktree management
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct WorktreeConfig {
25    /// Worktree storage mode (local or global)
26    #[serde(default)]
27    pub mode: WorktreeMode,
28
29    /// Base directory for worktrees
30    /// - Local mode: relative to repo root (e.g., ".worktrees")
31    /// - Global mode: absolute path or relative to workspace root
32    pub base_dir: PathBuf,
33
34    /// Default branch prefix for managed worktrees
35    pub prefix: String,
36
37    /// Automatically manage .gitignore for worktree directories
38    pub auto_gitignore: bool,
39
40    /// Default editor command for opening worktrees
41    pub default_editor: String,
42
43    /// Cleanup configuration
44    pub cleanup: WorktreeCleanupConfig,
45
46    /// Merge detection configuration
47    pub merge_detection: WorktreeMergeDetectionConfig,
48
49    /// Status display configuration
50    pub status: WorktreeStatusConfig,
51}
52
53/// Configuration for cleanup operations
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct WorktreeCleanupConfig {
56    /// Minimum age (hours) before worktree can be cleaned
57    pub age_threshold_hours: u64,
58
59    /// Verify remote branch exists before cleanup
60    pub verify_remote: bool,
61
62    /// Automatically delete branch after worktree removal
63    pub auto_delete_branch: bool,
64
65    /// Require confirmation for bulk operations
66    pub require_confirmation: bool,
67}
68
69/// Configuration for merge detection
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct WorktreeMergeDetectionConfig {
72    /// Enable GitHub CLI integration for PR status
73    pub use_github_cli: bool,
74
75    /// Methods to use for merge detection (in order of preference)
76    pub methods: Vec<String>,
77
78    /// Main branches to check merges against
79    pub main_branches: Vec<String>,
80}
81
82/// Configuration for status display
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct WorktreeStatusConfig {
85    /// Show file lists in status output
86    pub show_files: bool,
87
88    /// Maximum number of files to display
89    pub max_files_shown: usize,
90
91    /// Show commit messages for unpushed commits
92    pub show_commit_messages: bool,
93
94    /// Maximum number of commits to display
95    pub max_commits_shown: usize,
96}
97
98impl Default for WorktreeConfig {
99    fn default() -> Self {
100        Self {
101            mode: WorktreeMode::default(),
102            base_dir: PathBuf::from(".worktrees"),
103            prefix: "vibe-ws/".to_string(),
104            auto_gitignore: true,
105            default_editor: "code".to_string(),
106            cleanup: WorktreeCleanupConfig::default(),
107            merge_detection: WorktreeMergeDetectionConfig::default(),
108            status: WorktreeStatusConfig::default(),
109        }
110    }
111}
112
113impl Default for WorktreeCleanupConfig {
114    fn default() -> Self {
115        Self {
116            age_threshold_hours: 24,
117            verify_remote: true,
118            auto_delete_branch: false,
119            require_confirmation: true,
120        }
121    }
122}
123
124impl Default for WorktreeMergeDetectionConfig {
125    fn default() -> Self {
126        Self {
127            use_github_cli: true,
128            methods: vec![
129                "standard".to_string(),
130                "squash".to_string(),
131                "github_pr".to_string(),
132                "file_content".to_string(),
133            ],
134            main_branches: vec!["main".to_string(), "master".to_string()],
135        }
136    }
137}
138
139impl Default for WorktreeStatusConfig {
140    fn default() -> Self {
141        Self {
142            show_files: true,
143            max_files_shown: 10,
144            show_commit_messages: true,
145            max_commits_shown: 5,
146        }
147    }
148}
149
150impl WorktreeConfig {
151    /// Get the resolved base directory based on mode and configuration
152    pub fn get_resolved_base_dir(&self, repo_root: Option<&std::path::Path>) -> PathBuf {
153        match self.mode {
154            WorktreeMode::Local => {
155                if self.base_dir.is_absolute() {
156                    self.base_dir.clone()
157                } else if let Some(root) = repo_root {
158                    root.join(&self.base_dir)
159                } else {
160                    self.base_dir.clone() // Fallback to relative path
161                }
162            }
163            WorktreeMode::Global => {
164                if self.base_dir.is_absolute() {
165                    self.base_dir.clone()
166                } else {
167                    // Resolve to global location (matching operations.rs logic)
168                    if let Some(home) = dirs::home_dir() {
169                        home.join(".toolprint")
170                            .join("vibe-workspace")
171                            .join("worktrees")
172                    } else {
173                        std::env::temp_dir().join("vibe-worktrees")
174                    }
175                }
176            }
177        }
178    }
179
180    /// Load configuration from environment variables, falling back to defaults
181    pub fn from_env() -> Self {
182        let mut config = Self::default();
183
184        if let Ok(mode) = std::env::var("VIBE_WORKTREE_MODE") {
185            config.mode = match mode.to_lowercase().as_str() {
186                "global" => WorktreeMode::Global,
187                "local" => WorktreeMode::Local,
188                _ => WorktreeMode::Local,
189            };
190        }
191
192        if let Ok(base_dir) = std::env::var("VIBE_WORKTREE_BASE") {
193            config.base_dir = PathBuf::from(base_dir);
194        }
195
196        if let Ok(prefix) = std::env::var("VIBE_WORKTREE_PREFIX") {
197            config.prefix = prefix;
198        }
199
200        if let Ok(editor) = std::env::var("VIBE_WORKTREE_EDITOR") {
201            config.default_editor = editor;
202        }
203
204        config
205    }
206
207    /// Load configuration with environment variable overrides and validation
208    pub fn load_with_overrides() -> Result<Self, String> {
209        let mut config = Self::from_env();
210
211        // Apply additional environment overrides
212        if let Ok(auto_gitignore) = std::env::var("VIBE_WORKTREE_AUTO_GITIGNORE") {
213            config.auto_gitignore = auto_gitignore.parse().unwrap_or(config.auto_gitignore);
214        }
215
216        // Override cleanup settings
217        if let Ok(age_threshold) = std::env::var("VIBE_WORKTREE_AGE_THRESHOLD") {
218            if let Ok(hours) = age_threshold.parse::<u64>() {
219                config.cleanup.age_threshold_hours = hours;
220            }
221        }
222
223        if let Ok(verify_remote) = std::env::var("VIBE_WORKTREE_VERIFY_REMOTE") {
224            config.cleanup.verify_remote = verify_remote
225                .parse()
226                .unwrap_or(config.cleanup.verify_remote);
227        }
228
229        if let Ok(auto_delete) = std::env::var("VIBE_WORKTREE_AUTO_DELETE_BRANCH") {
230            config.cleanup.auto_delete_branch = auto_delete
231                .parse()
232                .unwrap_or(config.cleanup.auto_delete_branch);
233        }
234
235        // Override merge detection settings
236        if let Ok(use_github) = std::env::var("VIBE_WORKTREE_USE_GITHUB_CLI") {
237            config.merge_detection.use_github_cli = use_github
238                .parse()
239                .unwrap_or(config.merge_detection.use_github_cli);
240        }
241
242        if let Ok(methods) = std::env::var("VIBE_WORKTREE_MERGE_METHODS") {
243            config.merge_detection.methods =
244                methods.split(',').map(|s| s.trim().to_string()).collect();
245        }
246
247        if let Ok(main_branches) = std::env::var("VIBE_WORKTREE_MAIN_BRANCHES") {
248            config.merge_detection.main_branches = main_branches
249                .split(',')
250                .map(|s| s.trim().to_string())
251                .collect();
252        }
253
254        // Override status settings
255        if let Ok(show_files) = std::env::var("VIBE_WORKTREE_SHOW_FILES") {
256            config.status.show_files = show_files.parse().unwrap_or(config.status.show_files);
257        }
258
259        if let Ok(max_files) = std::env::var("VIBE_WORKTREE_MAX_FILES_SHOWN") {
260            if let Ok(count) = max_files.parse::<usize>() {
261                config.status.max_files_shown = count;
262            }
263        }
264
265        // Validate the final configuration
266        config.validate()?;
267        Ok(config)
268    }
269
270    /// Enhanced validation with more comprehensive checks
271    pub fn validate(&self) -> Result<(), String> {
272        // Basic validation (existing)
273        if self.prefix.is_empty() {
274            return Err("Worktree prefix cannot be empty".to_string());
275        }
276
277        if self.base_dir.to_string_lossy().is_empty() {
278            return Err("Base directory cannot be empty".to_string());
279        }
280
281        if self.cleanup.age_threshold_hours == 0 {
282            return Err("Age threshold must be greater than 0".to_string());
283        }
284
285        // Advanced validation
286        if self.prefix.contains("..") || self.prefix.contains('\0') {
287            return Err("Worktree prefix contains invalid characters".to_string());
288        }
289
290        if self.prefix.len() > 50 {
291            return Err("Worktree prefix is too long (max 50 characters)".to_string());
292        }
293
294        if self.merge_detection.methods.is_empty() {
295            return Err("At least one merge detection method must be configured".to_string());
296        }
297
298        if self.merge_detection.main_branches.is_empty() {
299            return Err("At least one main branch must be configured".to_string());
300        }
301
302        if self.cleanup.age_threshold_hours > 24 * 365 {
303            return Err("Age threshold is too high (max 1 year)".to_string());
304        }
305
306        if self.status.max_files_shown == 0 || self.status.max_files_shown > 100 {
307            return Err("Max files shown must be between 1 and 100".to_string());
308        }
309
310        if self.status.max_commits_shown == 0 || self.status.max_commits_shown > 50 {
311            return Err("Max commits shown must be between 1 and 50".to_string());
312        }
313
314        // Validate editor command
315        if self.default_editor.is_empty() {
316            return Err("Default editor cannot be empty".to_string());
317        }
318
319        // Basic validation of base directory - just check it's not completely empty
320        if self.base_dir.to_string_lossy().trim().is_empty() {
321            return Err("Base directory cannot be empty or whitespace".to_string());
322        }
323
324        Ok(())
325    }
326
327    /// Get configuration documentation for help system
328    pub fn get_help_text() -> &'static str {
329        r#"Worktree Configuration Options:
330
331Environment Variables:
332  VIBE_WORKTREE_MODE              Storage mode: local or global (default: local)
333  VIBE_WORKTREE_BASE              Base directory for worktrees (default: .worktrees)
334  VIBE_WORKTREE_PREFIX            Branch prefix for managed worktrees (default: vibe-ws/)
335  VIBE_WORKTREE_EDITOR            Default editor command (default: code)
336  VIBE_WORKTREE_AUTO_GITIGNORE    Auto-manage .gitignore (default: true)
337  VIBE_WORKTREE_AGE_THRESHOLD     Minimum age in hours for cleanup (default: 24)
338  VIBE_WORKTREE_VERIFY_REMOTE     Verify remote branch before cleanup (default: true)
339  VIBE_WORKTREE_AUTO_DELETE_BRANCH Auto-delete branch after cleanup (default: false)
340  VIBE_WORKTREE_USE_GITHUB_CLI    Use GitHub CLI for merge detection (default: true)
341  VIBE_WORKTREE_MERGE_METHODS     Comma-separated merge detection methods
342  VIBE_WORKTREE_MAIN_BRANCHES     Comma-separated main branch names
343  VIBE_WORKTREE_SHOW_FILES        Show file lists in status (default: true)
344  VIBE_WORKTREE_MAX_FILES_SHOWN   Max files to show in status (default: 10)
345
346Configuration File:
347  The worktree configuration is stored in ~/.toolprint/vibe-workspace/config.yaml
348  under the 'worktree' section. Repository-specific overrides can be configured
349  in the 'repositories[].worktree_config' section.
350"#
351    }
352
353    /// Create a sample configuration for documentation
354    pub fn sample_config_yaml() -> String {
355        serde_yaml::to_string(&Self::default())
356            .unwrap_or_else(|_| "# Error generating sample config".to_string())
357    }
358}
359
360/// Environment variable documentation
361pub const WORKTREE_ENV_VARS: &[(&str, &str, &str)] = &[
362    (
363        "VIBE_WORKTREE_MODE",
364        "local",
365        "Worktree storage mode (local or global)",
366    ),
367    (
368        "VIBE_WORKTREE_BASE",
369        ".worktrees",
370        "Base directory for worktrees",
371    ),
372    (
373        "VIBE_WORKTREE_PREFIX",
374        "vibe-ws/",
375        "Branch prefix for managed worktrees",
376    ),
377    ("VIBE_WORKTREE_EDITOR", "code", "Default editor command"),
378    (
379        "VIBE_WORKTREE_AUTO_GITIGNORE",
380        "true",
381        "Auto-manage .gitignore entries",
382    ),
383    (
384        "VIBE_WORKTREE_AGE_THRESHOLD",
385        "24",
386        "Minimum age in hours for cleanup eligibility",
387    ),
388    (
389        "VIBE_WORKTREE_VERIFY_REMOTE",
390        "true",
391        "Verify remote branch exists before cleanup",
392    ),
393    (
394        "VIBE_WORKTREE_AUTO_DELETE_BRANCH",
395        "false",
396        "Auto-delete branch after worktree removal",
397    ),
398    (
399        "VIBE_WORKTREE_USE_GITHUB_CLI",
400        "true",
401        "Use GitHub CLI for merge detection",
402    ),
403    (
404        "VIBE_WORKTREE_MERGE_METHODS",
405        "standard,squash,github_pr",
406        "Merge detection methods",
407    ),
408    (
409        "VIBE_WORKTREE_MAIN_BRANCHES",
410        "main,master",
411        "Main branches for merge detection",
412    ),
413    (
414        "VIBE_WORKTREE_SHOW_FILES",
415        "true",
416        "Show file lists in status output",
417    ),
418    (
419        "VIBE_WORKTREE_MAX_FILES_SHOWN",
420        "10",
421        "Maximum files to show in status",
422    ),
423];
424
425#[cfg(test)]
426mod config_tests {
427    use super::*;
428    use std::env;
429
430    #[test]
431    fn test_enhanced_validation() {
432        let mut config = WorktreeConfig::default();
433
434        // Valid configuration should pass
435        let result = config.validate();
436        if let Err(err) = &result {
437            eprintln!("Default config validation failed: {}", err);
438        }
439        assert!(result.is_ok());
440
441        // Test prefix validation
442        config.prefix = "..".to_string();
443        assert!(config.validate().is_err());
444        assert!(config
445            .validate()
446            .unwrap_err()
447            .contains("invalid characters"));
448
449        config.prefix = "x".repeat(60); // Too long
450        assert!(config.validate().is_err());
451        assert!(config.validate().unwrap_err().contains("too long"));
452
453        // Reset to valid
454        config.prefix = "test/".to_string();
455        assert!(config.validate().is_ok());
456
457        // Test empty methods
458        config.merge_detection.methods.clear();
459        assert!(config.validate().is_err());
460        assert!(config
461            .validate()
462            .unwrap_err()
463            .contains("merge detection method"));
464
465        // Test empty main branches
466        config.merge_detection.methods = vec!["standard".to_string()];
467        config.merge_detection.main_branches.clear();
468        assert!(config.validate().is_err());
469        assert!(config.validate().unwrap_err().contains("main branch"));
470
471        // Test file limits
472        config.merge_detection.main_branches = vec!["main".to_string()];
473        config.status.max_files_shown = 0;
474        assert!(config.validate().is_err());
475        assert!(config.validate().unwrap_err().contains("Max files shown"));
476
477        config.status.max_files_shown = 200;
478        assert!(config.validate().is_err());
479        assert!(config.validate().unwrap_err().contains("Max files shown"));
480    }
481
482    #[test]
483    fn test_load_with_overrides() {
484        // Set environment variables with valid values
485        env::set_var("VIBE_WORKTREE_PREFIX", "env-prefix/");
486        env::set_var("VIBE_WORKTREE_BASE", "/tmp/worktrees");
487        env::set_var("VIBE_WORKTREE_AGE_THRESHOLD", "48");
488        env::set_var("VIBE_WORKTREE_AUTO_GITIGNORE", "false");
489        env::set_var("VIBE_WORKTREE_MERGE_METHODS", "standard,custom");
490        env::set_var("VIBE_WORKTREE_MAIN_BRANCHES", "main,dev");
491        env::set_var("VIBE_WORKTREE_MAX_FILES_SHOWN", "20");
492
493        let config = WorktreeConfig::load_with_overrides().unwrap();
494
495        assert_eq!(config.prefix, "env-prefix/");
496        assert_eq!(config.base_dir, PathBuf::from("/tmp/worktrees"));
497        assert_eq!(config.cleanup.age_threshold_hours, 48);
498        assert_eq!(config.auto_gitignore, false);
499        assert_eq!(config.merge_detection.methods, vec!["standard", "custom"]);
500        assert_eq!(config.merge_detection.main_branches, vec!["main", "dev"]);
501        assert_eq!(config.status.max_files_shown, 20);
502
503        // Clean up
504        env::remove_var("VIBE_WORKTREE_PREFIX");
505        env::remove_var("VIBE_WORKTREE_BASE");
506        env::remove_var("VIBE_WORKTREE_AGE_THRESHOLD");
507        env::remove_var("VIBE_WORKTREE_AUTO_GITIGNORE");
508        env::remove_var("VIBE_WORKTREE_MERGE_METHODS");
509        env::remove_var("VIBE_WORKTREE_MAIN_BRANCHES");
510        env::remove_var("VIBE_WORKTREE_MAX_FILES_SHOWN");
511    }
512
513    #[test]
514    fn test_sample_config_generation() {
515        let yaml = WorktreeConfig::sample_config_yaml();
516        assert!(!yaml.is_empty());
517        assert!(yaml.contains("prefix"));
518        assert!(yaml.contains("base_dir"));
519    }
520
521    #[test]
522    fn test_help_text() {
523        let help = WorktreeConfig::get_help_text();
524        assert!(!help.is_empty());
525        assert!(help.contains("Environment Variables"));
526        assert!(help.contains("VIBE_WORKTREE_PREFIX"));
527        assert!(help.contains("Configuration File"));
528    }
529
530    #[test]
531    fn test_environment_variable_documentation() {
532        // Test that all documented environment variables are valid
533        for (env_var, default_value, description) in WORKTREE_ENV_VARS {
534            assert!(!env_var.is_empty());
535            assert!(!default_value.is_empty());
536            assert!(!description.is_empty());
537            assert!(env_var.starts_with("VIBE_WORKTREE_"));
538        }
539
540        assert!(WORKTREE_ENV_VARS.len() > 10); // Should have many env vars documented
541    }
542
543    #[test]
544    fn test_worktree_mode() {
545        // Test default mode
546        let config = WorktreeConfig::default();
547        assert_eq!(config.mode, WorktreeMode::Local);
548
549        // Test mode serialization/deserialization
550        let local_config = WorktreeConfig {
551            mode: WorktreeMode::Local,
552            ..Default::default()
553        };
554
555        let global_config = WorktreeConfig {
556            mode: WorktreeMode::Global,
557            ..Default::default()
558        };
559
560        // Test serialization
561        let local_yaml = serde_yaml::to_string(&local_config).unwrap();
562        let global_yaml = serde_yaml::to_string(&global_config).unwrap();
563
564        assert!(local_yaml.contains("mode: local"));
565        assert!(global_yaml.contains("mode: global"));
566
567        // Test deserialization
568        let deserialized_local: WorktreeConfig = serde_yaml::from_str(&local_yaml).unwrap();
569        let deserialized_global: WorktreeConfig = serde_yaml::from_str(&global_yaml).unwrap();
570
571        assert_eq!(deserialized_local.mode, WorktreeMode::Local);
572        assert_eq!(deserialized_global.mode, WorktreeMode::Global);
573    }
574
575    #[test]
576    fn test_environment_variable_mode_override() {
577        use std::env;
578
579        // Test local mode
580        env::set_var("VIBE_WORKTREE_MODE", "local");
581        let config = WorktreeConfig::from_env();
582        assert_eq!(config.mode, WorktreeMode::Local);
583
584        // Test global mode
585        env::set_var("VIBE_WORKTREE_MODE", "global");
586        let config = WorktreeConfig::from_env();
587        assert_eq!(config.mode, WorktreeMode::Global);
588
589        // Test invalid mode defaults to local
590        env::set_var("VIBE_WORKTREE_MODE", "invalid");
591        let config = WorktreeConfig::from_env();
592        assert_eq!(config.mode, WorktreeMode::Local);
593
594        // Clean up
595        env::remove_var("VIBE_WORKTREE_MODE");
596    }
597}