1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use tokio::fs;
6
7use crate::worktree::config::{
8 WorktreeCleanupConfig, WorktreeConfig, WorktreeMergeDetectionConfig, WorktreeMode,
9};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct WorkspaceConfig {
13 pub workspace: WorkspaceInfo,
14 pub repositories: Vec<Repository>,
15 pub groups: Vec<RepositoryGroup>,
16 pub apps: AppIntegrations,
17 #[serde(default)]
18 pub preferences: Option<Preferences>,
19 #[serde(default)]
20 pub claude_agents: Option<ClaudeAgentsIntegration>,
21 #[serde(default)]
22 pub worktree: WorktreeConfig,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct WorkspaceInfo {
27 pub name: String,
28 pub root: PathBuf,
29 pub auto_discover: bool,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Repository {
34 pub name: String,
35 pub path: PathBuf,
36 pub url: Option<String>,
37 pub branch: Option<String>,
38 pub apps: HashMap<String, AppConfig>,
39 #[serde(default)]
40 pub worktree_config: Option<RepositoryWorktreeConfig>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct RepositoryWorktreeConfig {
46 pub mode: Option<WorktreeMode>,
48
49 pub base_dir: Option<PathBuf>,
51
52 pub prefix: Option<String>,
54
55 pub cleanup: Option<WorktreeCleanupConfig>,
57
58 pub merge_detection: Option<WorktreeMergeDetectionConfig>,
60
61 pub disabled: Option<bool>,
63}
64
65impl RepositoryWorktreeConfig {
66 pub fn merge_with_global(&self, global: &WorktreeConfig) -> WorktreeConfig {
68 WorktreeConfig {
69 mode: self.mode.unwrap_or(global.mode),
70 base_dir: self
71 .base_dir
72 .clone()
73 .unwrap_or_else(|| global.base_dir.clone()),
74 prefix: self.prefix.clone().unwrap_or_else(|| global.prefix.clone()),
75 auto_gitignore: global.auto_gitignore, default_editor: global.default_editor.clone(), cleanup: self
78 .cleanup
79 .clone()
80 .unwrap_or_else(|| global.cleanup.clone()),
81 merge_detection: self
82 .merge_detection
83 .clone()
84 .unwrap_or_else(|| global.merge_detection.clone()),
85 status: global.status.clone(), }
87 }
88
89 pub fn is_enabled(&self) -> bool {
91 !self.disabled.unwrap_or(false)
92 }
93}
94
95impl Default for RepositoryWorktreeConfig {
96 fn default() -> Self {
97 Self {
98 mode: None,
99 base_dir: None,
100 prefix: None,
101 cleanup: None,
102 merge_detection: None,
103 disabled: None,
104 }
105 }
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109#[serde(untagged)]
110pub enum AppConfig {
111 Enabled(bool),
112 WithTemplate {
113 template: String,
114 },
115 WithConfig {
116 template: String,
117 #[serde(default)]
118 config: serde_json::Value,
119 },
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct RepositoryGroup {
124 pub name: String,
125 pub repos: Vec<String>,
126 pub apps: HashMap<String, AppIntegration>,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct AppIntegrations {
131 pub github: Option<GitHubIntegration>,
132 pub warp: Option<WarpIntegration>,
133 pub iterm2: Option<ITerm2Integration>,
134 pub vscode: Option<VSCodeIntegration>,
135 pub wezterm: Option<WezTermIntegration>,
136 pub cursor: Option<CursorIntegration>,
137 pub windsurf: Option<WindsurfIntegration>,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct GitHubIntegration {
142 pub enabled: bool,
143 pub token_source: String, }
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct WarpIntegration {
148 pub enabled: bool,
149 pub config_dir: PathBuf,
150 #[serde(default = "default_warp_template_dir")]
151 pub template_dir: PathBuf,
152 #[serde(default = "default_template_name")]
153 pub default_template: String,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct ITerm2Integration {
158 pub enabled: bool,
159 pub config_dir: PathBuf,
160 #[serde(default = "default_iterm2_template_dir")]
161 pub template_dir: PathBuf,
162 #[serde(default = "default_template_name")]
163 pub default_template: String,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct WezTermIntegration {
168 pub enabled: bool,
169 pub config_dir: PathBuf,
170 #[serde(default = "default_wezterm_template_dir")]
171 pub template_dir: PathBuf,
172 #[serde(default = "default_template_name")]
173 pub default_template: String,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct VSCodeIntegration {
178 pub enabled: bool,
179 pub workspace_dir: PathBuf,
180 #[serde(default = "default_vscode_template_dir")]
181 pub template_dir: PathBuf,
182 #[serde(default = "default_template_name")]
183 pub default_template: String,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct CursorIntegration {
188 pub enabled: bool,
189 pub workspace_dir: PathBuf,
190 #[serde(default = "default_cursor_template_dir")]
191 pub template_dir: PathBuf,
192 #[serde(default = "default_template_name")]
193 pub default_template: String,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct WindsurfIntegration {
198 pub enabled: bool,
199 pub workspace_dir: PathBuf,
200 #[serde(default = "default_windsurf_template_dir")]
201 pub template_dir: PathBuf,
202 #[serde(default = "default_template_name")]
203 pub default_template: String,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct ClaudeAgentsIntegration {
208 pub enabled: bool,
209 #[serde(default = "default_claude_agents_source_path")]
210 pub source_path: PathBuf,
211 #[serde(default = "default_claude_agents_target_path")]
212 pub target_path: PathBuf,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize, Default)]
216pub struct Preferences {
217 #[serde(default)]
218 pub page_sizes: PageSizes,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct PageSizes {
223 #[serde(default = "default_main_menu_page_size")]
224 pub main_menu: usize,
225 #[serde(default = "default_repository_list_page_size")]
226 pub repository_list: usize,
227 #[serde(default = "default_quick_launch_page_size")]
228 pub quick_launch: usize,
229 #[serde(default = "default_app_selection_page_size")]
230 pub app_selection: usize,
231 #[serde(default = "default_git_search_results_page_size")]
232 pub git_search_results: usize,
233 #[serde(default = "default_management_menus_page_size")]
234 pub management_menus: usize,
235 #[serde(default = "default_app_installer_page_size")]
236 pub app_installer: usize,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
240#[serde(untagged)]
241pub enum AppIntegration {
242 Simple(bool),
243 Warp { commands: Vec<String> },
244 VSCode { extensions: Vec<String> },
245 ITerm2 { profile: String },
246}
247
248impl Default for WorkspaceConfig {
249 fn default() -> Self {
250 let vibe_dir = super::constants::get_config_dir();
251
252 Self {
253 workspace: WorkspaceInfo {
254 name: "default".to_string(),
255 root: PathBuf::from("."),
256 auto_discover: true,
257 },
258 repositories: Vec::new(),
259 groups: Vec::new(),
260 apps: AppIntegrations {
261 github: Some(GitHubIntegration {
262 enabled: true,
263 token_source: "gh".to_string(),
264 }),
265 warp: Some(WarpIntegration {
266 enabled: true,
267 config_dir: dirs::home_dir()
268 .unwrap_or_default()
269 .join(".warp")
270 .join("launch_configurations"),
271 template_dir: vibe_dir.join("templates").join("warp"),
272 default_template: "default".to_string(),
273 }),
274 iterm2: Some(ITerm2Integration {
275 enabled: true,
276 config_dir: dirs::home_dir()
277 .unwrap_or_default()
278 .join("Library")
279 .join("Application Support")
280 .join("iTerm2")
281 .join("DynamicProfiles"),
282 template_dir: vibe_dir.join("templates").join("iterm2"),
283 default_template: "default".to_string(),
284 }),
285 wezterm: Some(WezTermIntegration {
286 enabled: true,
287 config_dir: dirs::config_dir()
288 .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".config"))
289 .join("wezterm"),
290 template_dir: vibe_dir.join("templates").join("wezterm"),
291 default_template: "default".to_string(),
292 }),
293 vscode: Some(VSCodeIntegration {
294 enabled: true,
295 workspace_dir: dirs::home_dir()
296 .unwrap_or_default()
297 .join(".vscode")
298 .join("workspaces"),
299 template_dir: vibe_dir.join("templates").join("vscode"),
300 default_template: "default".to_string(),
301 }),
302 cursor: Some(CursorIntegration {
303 enabled: true,
304 workspace_dir: dirs::home_dir()
305 .unwrap_or_default()
306 .join(".cursor")
307 .join("workspaces"),
308 template_dir: vibe_dir.join("templates").join("cursor"),
309 default_template: "default".to_string(),
310 }),
311 windsurf: Some(WindsurfIntegration {
312 enabled: true,
313 workspace_dir: dirs::home_dir()
314 .unwrap_or_default()
315 .join(".windsurf")
316 .join("workspaces"),
317 template_dir: vibe_dir.join("templates").join("windsurf"),
318 default_template: "default".to_string(),
319 }),
320 },
321 preferences: Some(Preferences::default()),
322 claude_agents: Some(ClaudeAgentsIntegration {
323 enabled: true,
324 source_path: PathBuf::from(".").join("wshobson").join("agents"),
325 target_path: dirs::home_dir()
326 .unwrap_or_default()
327 .join(".claude")
328 .join("agents"),
329 }),
330 worktree: WorktreeConfig::default(),
331 }
332 }
333}
334
335impl WorkspaceConfig {
336 pub async fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
337 let path = path.as_ref();
338
339 if !path.exists() {
340 return Ok(Self::default());
341 }
342
343 let contents = fs::read_to_string(path)
344 .await
345 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
346
347 let mut config: Self = serde_yaml::from_str(&contents)
348 .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
349
350 config.ensure_app_integrations_initialized().await?;
352
353 Ok(config)
354 }
355
356 pub async fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
357 let path = path.as_ref();
358
359 if let Some(parent) = path.parent() {
361 fs::create_dir_all(parent).await.with_context(|| {
362 format!("Failed to create config directory: {}", parent.display())
363 })?;
364 }
365
366 let yaml = serde_yaml::to_string(self).context("Failed to serialize config to YAML")?;
367
368 fs::write(path, yaml)
369 .await
370 .with_context(|| format!("Failed to write config file: {}", path.display()))?;
371
372 Ok(())
373 }
374
375 pub fn get_repository(&self, name: &str) -> Option<&Repository> {
376 self.repositories.iter().find(|repo| repo.name == name)
377 }
378
379 pub fn get_repository_flexible(&self, name: &str) -> Option<&Repository> {
381 if let Some(repo) = self.get_repository(name) {
383 return Some(repo);
384 }
385
386 let lower_name = name.to_lowercase();
388 if let Some(repo) = self
389 .repositories
390 .iter()
391 .find(|repo| repo.name.to_lowercase() == lower_name)
392 {
393 return Some(repo);
394 }
395
396 if let Some((_owner, repo_name)) = name.split_once('/') {
398 if let Some(repo) = self.get_repository(repo_name) {
400 return Some(repo);
401 }
402
403 let lower_repo_name = repo_name.to_lowercase();
405 if let Some(repo) = self
406 .repositories
407 .iter()
408 .find(|repo| repo.name.to_lowercase() == lower_repo_name)
409 {
410 return Some(repo);
411 }
412 }
413
414 let lower_search = name.to_lowercase();
416 self.repositories.iter().find(|repo| {
417 if let Some(url) = &repo.url {
418 let lower_url = url.to_lowercase();
419 lower_url.contains(&lower_search) ||
421 lower_url.split('/').next_back()
423 .map(|last| last.trim_end_matches(".git") == lower_search)
424 .unwrap_or(false)
425 } else {
426 false
427 }
428 })
429 }
430
431 pub fn get_repositories_in_group(&self, group_name: &str) -> Vec<&Repository> {
432 if let Some(group) = self.groups.iter().find(|g| g.name == group_name) {
433 group
434 .repos
435 .iter()
436 .filter_map(|repo_name| self.get_repository(repo_name))
437 .collect()
438 } else {
439 Vec::new()
440 }
441 }
442
443 pub fn add_repository(&mut self, repo: Repository) {
444 self.repositories.retain(|r| r.name != repo.name);
446 self.repositories.push(repo);
447 }
448
449 pub fn add_group(&mut self, group: RepositoryGroup) {
450 self.groups.retain(|g| g.name != group.name);
452 self.groups.push(group);
453 }
454
455 pub async fn ensure_app_integrations_initialized(&mut self) -> Result<()> {
458 let vibe_dir = super::constants::get_config_dir();
459
460 if self.apps.wezterm.is_none() {
462 self.apps.wezterm = Some(WezTermIntegration {
463 enabled: true,
464 config_dir: dirs::config_dir()
465 .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".config"))
466 .join("wezterm"),
467 template_dir: vibe_dir.join("templates").join("wezterm"),
468 default_template: "default".to_string(),
469 });
470 }
471
472 if self.apps.warp.is_none() {
474 self.apps.warp = Some(WarpIntegration {
475 enabled: true,
476 config_dir: dirs::home_dir()
477 .unwrap_or_default()
478 .join(".warp")
479 .join("launch_configurations"),
480 template_dir: vibe_dir.join("templates").join("warp"),
481 default_template: "default".to_string(),
482 });
483 }
484
485 if self.apps.iterm2.is_none() {
486 self.apps.iterm2 = Some(ITerm2Integration {
487 enabled: true,
488 config_dir: dirs::home_dir()
489 .unwrap_or_default()
490 .join("Library")
491 .join("Application Support")
492 .join("iTerm2")
493 .join("DynamicProfiles"),
494 template_dir: vibe_dir.join("templates").join("iterm2"),
495 default_template: "default".to_string(),
496 });
497 }
498
499 if self.apps.vscode.is_none() {
500 self.apps.vscode = Some(VSCodeIntegration {
501 enabled: true,
502 workspace_dir: dirs::home_dir()
503 .unwrap_or_default()
504 .join(".vscode")
505 .join("workspaces"),
506 template_dir: vibe_dir.join("templates").join("vscode"),
507 default_template: "default".to_string(),
508 });
509 }
510
511 if self.apps.cursor.is_none() {
512 self.apps.cursor = Some(CursorIntegration {
513 enabled: true,
514 workspace_dir: dirs::home_dir()
515 .unwrap_or_default()
516 .join(".cursor")
517 .join("workspaces"),
518 template_dir: vibe_dir.join("templates").join("cursor"),
519 default_template: "default".to_string(),
520 });
521 }
522
523 if self.apps.windsurf.is_none() {
524 self.apps.windsurf = Some(WindsurfIntegration {
525 enabled: true,
526 workspace_dir: dirs::home_dir()
527 .unwrap_or_default()
528 .join(".windsurf")
529 .join("workspaces"),
530 template_dir: vibe_dir.join("templates").join("windsurf"),
531 default_template: "default".to_string(),
532 });
533 }
534
535 if self.claude_agents.is_none() {
537 self.claude_agents = Some(ClaudeAgentsIntegration {
538 enabled: true,
539 source_path: self.workspace.root.join("wshobson").join("agents"),
540 target_path: dirs::home_dir()
541 .unwrap_or_default()
542 .join(".claude")
543 .join("agents"),
544 });
545 }
546
547 Ok(())
548 }
549
550 pub fn get_worktree_config_for_repo(&self, repo_name: &str) -> WorktreeConfig {
552 if let Some(repo) = self.repositories.iter().find(|r| r.name == repo_name) {
553 if let Some(repo_config) = &repo.worktree_config {
554 if repo_config.is_enabled() {
555 return repo_config.merge_with_global(&self.worktree);
556 }
557 }
558 }
559
560 self.worktree.clone()
562 }
563
564 pub fn is_worktree_enabled_for_repo(&self, repo_name: &str) -> bool {
566 if let Some(repo) = self.repositories.iter().find(|r| r.name == repo_name) {
567 if let Some(repo_config) = &repo.worktree_config {
568 return repo_config.is_enabled();
569 }
570 }
571
572 true }
574}
575
576impl Repository {
577 pub fn new<S: Into<String>, P: Into<PathBuf>>(name: S, path: P) -> Self {
578 Self {
579 name: name.into(),
580 path: path.into(),
581 url: None,
582 branch: None,
583 apps: HashMap::new(),
584 worktree_config: None,
585 }
586 }
587
588 pub fn with_url<S: Into<String>>(mut self, url: S) -> Self {
589 self.url = Some(url.into());
590 self
591 }
592
593 pub fn with_branch<S: Into<String>>(mut self, branch: S) -> Self {
594 self.branch = Some(branch.into());
595 self
596 }
597
598 pub fn enable_app<S: Into<String>>(mut self, app: S) -> Self {
599 self.apps.insert(app.into(), AppConfig::Enabled(true));
600 self
601 }
602
603 pub fn enable_app_with_template<S: Into<String>, T: Into<String>>(
604 mut self,
605 app: S,
606 template: T,
607 ) -> Self {
608 self.apps.insert(
609 app.into(),
610 AppConfig::WithTemplate {
611 template: template.into(),
612 },
613 );
614 self
615 }
616
617 pub fn is_app_enabled(&self, app: &str) -> bool {
618 match self.apps.get(app) {
619 Some(AppConfig::Enabled(enabled)) => *enabled,
620 Some(AppConfig::WithTemplate { .. }) => true,
621 Some(AppConfig::WithConfig { .. }) => true,
622 None => false,
623 }
624 }
625
626 pub fn get_app_template(&self, app: &str) -> Option<&str> {
627 match self.apps.get(app) {
628 Some(AppConfig::WithTemplate { template }) => Some(template),
629 Some(AppConfig::WithConfig { template, .. }) => Some(template),
630 _ => None,
631 }
632 }
633}
634
635impl AppConfig {
636 pub fn is_enabled(&self) -> bool {
637 match self {
638 AppConfig::Enabled(enabled) => *enabled,
639 AppConfig::WithTemplate { .. } => true,
640 AppConfig::WithConfig { .. } => true,
641 }
642 }
643}
644
645fn default_template_name() -> String {
647 "default".to_string()
648}
649
650fn default_warp_template_dir() -> PathBuf {
651 dirs::home_dir()
652 .unwrap_or_default()
653 .join(super::constants::CONFIG_DIR_PATH)
654 .join("templates")
655 .join("warp")
656}
657
658fn default_iterm2_template_dir() -> PathBuf {
659 dirs::home_dir()
660 .unwrap_or_default()
661 .join(super::constants::CONFIG_DIR_PATH)
662 .join("templates")
663 .join("iterm2")
664}
665
666fn default_wezterm_template_dir() -> PathBuf {
667 dirs::home_dir()
668 .unwrap_or_default()
669 .join(super::constants::CONFIG_DIR_PATH)
670 .join("templates")
671 .join("wezterm")
672}
673
674fn default_vscode_template_dir() -> PathBuf {
675 dirs::home_dir()
676 .unwrap_or_default()
677 .join(super::constants::CONFIG_DIR_PATH)
678 .join("templates")
679 .join("vscode")
680}
681
682fn default_cursor_template_dir() -> PathBuf {
683 dirs::home_dir()
684 .unwrap_or_default()
685 .join(super::constants::CONFIG_DIR_PATH)
686 .join("templates")
687 .join("cursor")
688}
689
690fn default_windsurf_template_dir() -> PathBuf {
691 dirs::home_dir()
692 .unwrap_or_default()
693 .join(super::constants::CONFIG_DIR_PATH)
694 .join("templates")
695 .join("windsurf")
696}
697
698fn default_claude_agents_source_path() -> PathBuf {
699 PathBuf::from(".").join("wshobson").join("agents")
700}
701
702fn default_claude_agents_target_path() -> PathBuf {
703 dirs::home_dir()
704 .unwrap_or_default()
705 .join(".claude")
706 .join("agents")
707}
708
709fn default_main_menu_page_size() -> usize {
711 15
712}
713
714fn default_repository_list_page_size() -> usize {
715 15
716}
717
718fn default_quick_launch_page_size() -> usize {
719 9
720}
721
722fn default_app_selection_page_size() -> usize {
723 10
724}
725
726fn default_git_search_results_page_size() -> usize {
727 15
728}
729
730fn default_management_menus_page_size() -> usize {
731 10
732}
733
734fn default_app_installer_page_size() -> usize {
735 15
736}
737
738impl Default for PageSizes {
739 fn default() -> Self {
740 Self {
741 main_menu: default_main_menu_page_size(),
742 repository_list: default_repository_list_page_size(),
743 quick_launch: default_quick_launch_page_size(),
744 app_selection: default_app_selection_page_size(),
745 git_search_results: default_git_search_results_page_size(),
746 management_menus: default_management_menus_page_size(),
747 app_installer: default_app_installer_page_size(),
748 }
749 }
750}
751
752impl PageSizes {
753 pub fn validate(&self) -> Result<()> {
755 if self.quick_launch == 0 || self.quick_launch > 9 {
756 anyhow::bail!("quick_launch page size must be between 1 and 9 (limited by number key shortcuts), got {}", self.quick_launch);
757 }
758
759 let sizes = [
760 ("main_menu", self.main_menu),
761 ("repository_list", self.repository_list),
762 ("app_selection", self.app_selection),
763 ("git_search_results", self.git_search_results),
764 ("management_menus", self.management_menus),
765 ("app_installer", self.app_installer),
766 ];
767
768 for (name, size) in &sizes {
769 if *size == 0 || *size > 15 {
770 anyhow::bail!("{} page size must be between 1 and 15, got {}", name, size);
771 }
772 }
773
774 Ok(())
775 }
776}