1use anyhow::{Context, Result};
2use colored::*;
3use console::style;
4use std::path::{Path, PathBuf};
5use tracing::{info, warn};
6
7use crate::display_println;
8
9use crate::cache::{GitStatusCache, RepositoryCache};
10
11use super::{
12 config::{AppConfig, Repository, WorkspaceConfig},
13 discovery::{
14 discover_git_repositories, get_current_branch, get_remote_url, get_repository_name,
15 },
16 operations::{get_git_status, GitOperation, GitStatus},
17 templates::TemplateManager,
18};
19
20#[derive(Debug, Clone)]
21pub struct AppSelection {
22 pub app: String,
23 pub selected: bool,
24 pub template: Option<String>,
25 pub currently_configured: bool,
26}
27
28#[derive(Debug, Clone)]
29pub struct AppChoice {
30 pub app: String,
31 pub display: String,
32 pub is_configured: bool,
33}
34
35#[derive(Debug, Default)]
36pub struct AppConfigState {
37 pub warp: Option<String>, pub iterm2: Option<String>,
39 pub wezterm: Option<String>,
40 pub vscode: Option<String>,
41 pub cursor: Option<String>,
42 pub windsurf: Option<String>,
43}
44
45#[derive(Debug, Clone)]
46pub struct BackupInfo {
47 pub file_name: String,
48 pub path: PathBuf,
49 pub size: u64,
50 pub created: std::time::SystemTime,
51 pub display_name: String,
52 pub contents: Option<BackupContents>,
53}
54
55#[derive(Debug, Clone)]
56pub struct BackupContents {
57 pub has_config: bool,
58 pub has_state: bool,
59 pub has_templates: bool,
60 pub app_configs: Vec<String>,
61 pub total_files: usize,
62}
63
64#[derive(Debug, Clone)]
65pub struct RepoWithStatus {
66 pub name: String,
67 pub path: String,
68 pub apps: Vec<(String, String)>, pub git_status: GitStatus,
70 pub display_string: String, }
72
73pub struct WorkspaceManager {
74 config_path: PathBuf,
75 config: WorkspaceConfig,
76 template_manager: TemplateManager,
77 repo_cache: Option<RepositoryCache>,
78 git_cache: Option<GitStatusCache>,
79}
80
81impl WorkspaceManager {
82 pub async fn new(config_path: PathBuf) -> Result<Self> {
83 let config = WorkspaceConfig::load_from_file(&config_path).await?;
84
85 let vibe_dir = super::constants::get_config_dir();
86 let template_manager = TemplateManager::new(vibe_dir.join("templates"));
87
88 let cache_dir = vibe_dir.join("cache");
90 let repo_cache = Self::init_repository_cache(&cache_dir).await.ok();
91 let git_cache = Self::init_git_status_cache(&cache_dir).await.ok();
92
93 Ok(Self {
94 config_path,
95 config,
96 template_manager,
97 repo_cache,
98 git_cache,
99 })
100 }
101
102 pub async fn new_with_root_override(
103 config_path: PathBuf,
104 root_override: Option<PathBuf>,
105 ) -> Result<Self> {
106 let mut config = WorkspaceConfig::load_from_file(&config_path).await?;
107
108 if let Some(root) = root_override {
110 let expanded_root = crate::utils::fs::expand_tilde(&root);
111 info!("Overriding workspace root to: {}", expanded_root.display());
112 config.workspace.root = expanded_root;
113 }
114
115 let vibe_dir = super::constants::get_config_dir();
116 let template_manager = TemplateManager::new(vibe_dir.join("templates"));
117
118 let cache_dir = vibe_dir.join("cache");
120 let repo_cache = Self::init_repository_cache(&cache_dir).await.ok();
121 let git_cache = Self::init_git_status_cache(&cache_dir).await.ok();
122
123 Ok(Self {
124 config_path,
125 config,
126 template_manager,
127 repo_cache,
128 git_cache,
129 })
130 }
131
132 pub async fn init_workspace(&mut self, name: &str, root: &Path) -> Result<()> {
133 info!("Initializing workspace '{}' in {}", name, root.display());
134
135 self.config.workspace.name = name.to_string();
137 self.config.workspace.root = root.to_path_buf();
138
139 if self.config.workspace.auto_discover {
141 let discovered = discover_git_repositories(root, 3).await?;
142
143 for repo_path in discovered {
144 let repo_name =
145 get_repository_name(&repo_path).unwrap_or_else(|| "unknown".to_string());
146
147 let relative_path = repo_path
148 .strip_prefix(root)
149 .unwrap_or(&repo_path)
150 .to_path_buf();
151
152 let mut repo = Repository::new(repo_name, relative_path);
153
154 if let Ok(Some(url)) = get_remote_url(&repo_path) {
156 repo = repo.with_url(url);
157 }
158
159 if let Ok(Some(branch)) = get_current_branch(&repo_path) {
160 repo = repo.with_branch(branch);
161 }
162
163 self.config.add_repository(repo);
164 }
165 }
166
167 self.save_config().await?;
169
170 if let Err(e) = super::claude_agents::configure_claude_agents(&self.config).await {
172 warn!("Failed to configure Claude agents: {}", e);
173 }
174
175 if let Err(e) = self.init_templates().await {
177 warn!("Failed to initialize default templates: {}", e);
178 }
179
180 Ok(())
181 }
182
183 pub async fn discover_repositories(&self, path: &Path, depth: usize) -> Result<Vec<PathBuf>> {
184 discover_git_repositories(path, depth).await
185 }
186
187 pub async fn add_discovered_repositories(&mut self, repo_paths: &[PathBuf]) -> Result<()> {
188 let workspace_root = self.config.workspace.root.clone();
189
190 for repo_path in repo_paths {
191 let repo_name = get_repository_name(repo_path).unwrap_or_else(|| "unknown".to_string());
192
193 let relative_path = repo_path
194 .strip_prefix(&workspace_root)
195 .unwrap_or(repo_path)
196 .to_path_buf();
197
198 let mut repo = Repository::new(repo_name, relative_path);
199
200 if let Ok(Some(url)) = get_remote_url(repo_path) {
202 repo = repo.with_url(url);
203 }
204
205 if let Ok(Some(branch)) = get_current_branch(repo_path) {
206 repo = repo.with_branch(branch);
207 }
208
209 self.config.add_repository(repo);
210 }
211
212 self.save_config().await?;
213 Ok(())
214 }
215
216 pub async fn show_status(
217 &self,
218 dirty_only: bool,
219 format: &str,
220 group: Option<&str>,
221 ) -> Result<()> {
222 use super::repo_analyzer::analyze_workspace;
223 use crate::ui::hierarchical_display::render_status_summary;
224
225 if format == "json" || format == "compact" {
227 return self.show_status_legacy(dirty_only, format, group).await;
228 }
229
230 println!("{} Analyzing repository status...", style("đ").blue());
231
232 let analysis = analyze_workspace(&self.config.workspace.root, &self.config, 3).await?;
234
235 render_status_summary(&analysis).await;
237
238 Ok(())
249 }
250
251 async fn show_status_legacy(
253 &self,
254 dirty_only: bool,
255 format: &str,
256 group: Option<&str>,
257 ) -> Result<()> {
258 let repositories = if let Some(group_name) = group {
259 self.config.get_repositories_in_group(group_name)
260 } else {
261 self.config.repositories.iter().collect()
262 };
263
264 if repositories.is_empty() {
265 println!("{} No repositories found", style("âš").yellow());
266 return Ok(());
267 }
268
269 let mut statuses = Vec::new();
270
271 for repo in repositories {
272 let repo_path = self.config.workspace.root.join(&repo.path);
273
274 match get_git_status(&repo_path).await {
275 Ok(status) => {
276 if !dirty_only || status.is_dirty() {
277 statuses.push(status);
278 }
279 }
280 Err(e) => {
281 warn!("Failed to get status for {}: {}", repo.name, e);
282 eprintln!(
283 "{} Failed to get status for {}: {}",
284 style("â ").yellow(),
285 style(&repo.name).cyan(),
286 e
287 );
288 }
289 }
290 }
291
292 if statuses.is_empty() {
293 if dirty_only {
294 println!("{} All repositories are clean", style("â").green());
295 } else {
296 println!("{} No repositories to display", style("âš").yellow());
297 }
298 return Ok(());
299 }
300
301 match format {
302 "json" => {
303 let json = serde_json::to_string_pretty(&statuses)
304 .context("Failed to serialize status to JSON")?;
305 println!("{json}");
306 }
307 "compact" => {
308 for status in &statuses {
309 let indicator = if status.clean {
310 "â".green()
311 } else {
312 "â".red()
313 };
314 println!("{} {}", indicator, status.repository_name.cyan());
315 }
316 }
317 _ => unreachable!("Legacy status only handles json and compact formats"),
318 }
319
320 Ok(())
321 }
322
323 pub async fn execute_command(
324 &self,
325 command: &str,
326 repos: Option<&str>,
327 group: Option<&str>,
328 parallel: bool,
329 ) -> Result<()> {
330 let repositories = self.get_target_repositories(repos, group);
331
332 if repositories.is_empty() {
333 println!(
334 "{} No repositories found to execute command on",
335 style("âš").yellow()
336 );
337 return Ok(());
338 }
339
340 println!(
341 "{} Executing '{}' on {} repositories...",
342 style("âĄ").blue(),
343 style(command).cyan(),
344 repositories.len()
345 );
346
347 let operation = GitOperation::Custom(command.to_string());
348
349 if parallel {
350 let mut tasks = Vec::new();
352
353 for repo in repositories {
354 let repo_path = self.config.workspace.root.join(&repo.path);
355 let operation = operation.clone();
356 let repo_name = repo.name.clone();
357
358 let task =
359 tokio::spawn(async move { (repo_name, operation.execute(&repo_path).await) });
360
361 tasks.push(task);
362 }
363
364 for task in tasks {
366 match task.await {
367 Ok((repo_name, result)) => match result {
368 Ok(output) => {
369 if !output.trim().is_empty() {
370 println!(
371 "{} {}:\n{}",
372 style("â").green(),
373 style(&repo_name).cyan(),
374 output
375 );
376 } else {
377 println!(
378 "{} {} (no output)",
379 style("â").green(),
380 style(&repo_name).cyan()
381 );
382 }
383 }
384 Err(e) => {
385 eprintln!(
386 "{} {} failed: {}",
387 style("â").red(),
388 style(&repo_name).cyan(),
389 e
390 );
391 }
392 },
393 Err(e) => {
394 eprintln!("{} Task failed: {}", style("â").red(), e);
395 }
396 }
397 }
398 } else {
399 for repo in repositories {
401 let repo_path = self.config.workspace.root.join(&repo.path);
402
403 print!(
404 "{} Executing on {}... ",
405 style("â").dim(),
406 style(&repo.name).cyan()
407 );
408
409 match operation.execute(&repo_path).await {
410 Ok(output) => {
411 println!("{}", style("â").green());
412 if !output.trim().is_empty() {
413 println!("{output}");
414 }
415 }
416 Err(e) => {
417 println!("{}", style("â").red());
418 eprintln!(" Error: {e}");
419 }
420 }
421 }
422 }
423
424 Ok(())
425 }
426
427 fn get_target_repositories(
428 &self,
429 repos: Option<&str>,
430 group: Option<&str>,
431 ) -> Vec<&Repository> {
432 if let Some(group_name) = group {
433 self.config.get_repositories_in_group(group_name)
434 } else if let Some(repo_names) = repos {
435 repo_names
436 .split(',')
437 .filter_map(|name| self.config.get_repository(name.trim()))
438 .collect()
439 } else {
440 self.config.repositories.iter().collect()
441 }
442 }
443
444 pub fn get_workspace_root(&self) -> &PathBuf {
445 &self.config.workspace.root
446 }
447
448 pub fn get_config_path(&self) -> &PathBuf {
449 &self.config_path
450 }
451
452 pub fn config(&self) -> &WorkspaceConfig {
453 &self.config
454 }
455
456 pub fn config_mut(&mut self) -> &mut WorkspaceConfig {
457 &mut self.config
458 }
459
460 pub async fn add_repository(&mut self, repo: Repository) -> Result<()> {
461 self.config.add_repository(repo);
462 self.save_config().await
463 }
464
465 pub fn get_config(&self) -> &WorkspaceConfig {
466 &self.config
467 }
468
469 pub fn get_template_manager(&self) -> &TemplateManager {
470 &self.template_manager
471 }
472
473 pub async fn scan_repositories(
475 &mut self,
476 scan_path: &Path,
477 depth: usize,
478 import: bool,
479 restore: bool,
480 clean: bool,
481 ) -> Result<()> {
482 use super::config_validator::{deduplicate_config, validate_config};
483 use super::repo_analyzer::analyze_workspace;
484 use super::sync_operations::{execute_sync_operations, print_sync_summary, SyncOptions};
485 use crate::ui::hierarchical_display::{render_workspace_analysis, DisplayOptions};
486
487 println!(
488 "{} Scanning repositories in {} (depth: {})",
489 style("đ").blue(),
490 style(scan_path.display()).cyan(),
491 depth
492 );
493
494 let validation_report = validate_config(&self.config, scan_path)?;
496 if validation_report.has_issues() {
497 println!();
498 validation_report.print_report();
499
500 if !validation_report.duplicates.is_empty() {
502 println!(
503 "{} Auto-fixing duplicate repositories...",
504 style("đ§").blue()
505 );
506 let dedup_report = deduplicate_config(&mut self.config, scan_path)?;
507
508 if dedup_report.duplicates.len() < validation_report.duplicates.len() {
509 println!(
510 "{} Removed {} duplicate entries",
511 style("â").green(),
512 validation_report.duplicates.len() - dedup_report.duplicates.len()
513 );
514 self.save_config().await?;
516 }
517 println!();
518 }
519 }
520
521 let analysis = analyze_workspace(scan_path, &self.config, depth).await?;
523
524 let display_options = DisplayOptions::default();
526 render_workspace_analysis(&analysis, &display_options);
527
528 let mut sync_options = SyncOptions::new();
530 if import {
531 sync_options = sync_options.with_import();
532 }
533 if restore {
534 sync_options = sync_options.with_restore();
535 }
536 if clean {
537 sync_options = sync_options.with_clean();
538 }
539
540 if sync_options.has_actions() {
542 print_sync_summary(&analysis, &sync_options);
543
544 execute_sync_operations(scan_path, &mut self.config, &analysis, &sync_options).await?;
546
547 self.save_config().await?;
549
550 println!();
552 println!("{} Updated workspace state:", style("đ").blue().bold());
553 println!("{}", "â".repeat(30));
554
555 let updated_analysis = analyze_workspace(scan_path, &self.config, depth).await?;
556 render_workspace_analysis(&updated_analysis, &display_options);
557 }
558
559 Ok(())
560 }
561
562 pub async fn sync_repositories(
564 &self,
565 fetch_only: bool,
566 prune: bool,
567 save_dirty: bool,
568 group: Option<&str>,
569 ) -> Result<()> {
570 let repositories = if let Some(group_name) = group {
571 self.config.get_repositories_in_group(group_name)
572 } else {
573 self.config.repositories.iter().collect()
574 };
575
576 if repositories.is_empty() {
577 println!("{} No repositories found", style("âš").yellow());
578 return Ok(());
579 }
580
581 let action = if fetch_only { "Fetching" } else { "Syncing" };
582 println!(
583 "{} {} {} repositories...",
584 style("đ").blue(),
585 action,
586 repositories.len()
587 );
588
589 if save_dirty {
590 println!(
591 "{} Auto-commit mode enabled - dirty repositories will be committed to dirty/{{timestamp}} branches",
592 style("đž").blue()
593 );
594 }
595
596 let mut operations = vec![GitOperation::Fetch];
597 if prune {
598 operations.push(GitOperation::Custom("fetch --prune".to_string()));
599 }
600 if !fetch_only {
601 operations.push(GitOperation::Pull);
602 }
603
604 for repo in repositories {
605 let repo_path = self.config.workspace.root.join(&repo.path);
606
607 print!("{} {}... ", style("â").dim(), style(&repo.name).cyan());
608
609 if save_dirty {
611 if let Err(e) = self.handle_dirty_repository(&repo_path).await {
612 println!("{} (dirty handling failed: {})", style("â ī¸").yellow(), e);
613 continue;
614 }
615 }
616
617 let mut success = true;
618 for operation in &operations {
619 match operation.execute(&repo_path).await {
620 Ok(_) => {}
621 Err(e) => {
622 if e.to_string().contains("dirty") && !save_dirty {
623 println!(
624 "{} (dirty working directory - use --save-dirty to auto-commit)",
625 style("â ī¸").yellow()
626 );
627 } else {
628 println!("{}", style("â").red());
629 eprintln!(" Error: {e}");
630 }
631 success = false;
632 break;
633 }
634 }
635 }
636
637 if success {
638 println!("{}", style("â").green());
639 }
640 }
641
642 Ok(())
643 }
644
645 async fn handle_dirty_repository(&self, repo_path: &Path) -> Result<()> {
647 use chrono::Utc;
648 use std::process::Command;
649
650 let status_output = Command::new("git")
652 .args(["status", "--porcelain"])
653 .current_dir(repo_path)
654 .output()?;
655
656 if status_output.stdout.is_empty() {
657 return Ok(());
659 }
660
661 let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
663 let branch_name = format!("dirty/{timestamp}");
664
665 let current_branch_output = Command::new("git")
667 .args(["branch", "--show-current"])
668 .current_dir(repo_path)
669 .output()?;
670 let current_branch = String::from_utf8_lossy(¤t_branch_output.stdout)
671 .trim()
672 .to_string();
673
674 Command::new("git")
676 .args(["checkout", "-b", &branch_name])
677 .current_dir(repo_path)
678 .output()?;
679
680 Command::new("git")
682 .args(["add", "-A"])
683 .current_dir(repo_path)
684 .output()?;
685
686 let commit_message = format!("WIP: auto-saved dirty changes from {current_branch}");
688 Command::new("git")
689 .args(["commit", "-m", &commit_message])
690 .current_dir(repo_path)
691 .output()?;
692
693 Command::new("git")
695 .args(["checkout", ¤t_branch])
696 .current_dir(repo_path)
697 .output()?;
698
699 Ok(())
700 }
701
702 pub async fn save_config(&self) -> Result<()> {
703 self.config.save_to_file(&self.config_path).await
704 }
705
706 pub async fn init_config(
707 &mut self,
708 name: Option<&str>,
709 root: Option<&Path>,
710 auto_discover: bool,
711 ) -> Result<()> {
712 info!("Initializing workspace configuration");
713
714 if let Some(n) = name {
716 self.config.workspace.name = n.to_string();
717 } else {
718 let current_dir = std::env::current_dir()?;
719 self.config.workspace.name = current_dir
720 .file_name()
721 .map(|n| n.to_string_lossy().to_string())
722 .unwrap_or_else(|| "workspace".to_string());
723 }
724
725 if let Some(r) = root {
727 self.config.workspace.root = r.to_path_buf();
728 } else {
729 self.config.workspace.root = std::env::current_dir()?;
730 }
731
732 self.config.workspace.auto_discover = auto_discover;
734
735 if auto_discover {
737 let discovered = discover_git_repositories(&self.config.workspace.root, 3).await?;
738 for repo_path in discovered {
739 let repo_name =
740 get_repository_name(&repo_path).unwrap_or_else(|| "unknown".to_string());
741
742 let relative_path = repo_path
743 .strip_prefix(&self.config.workspace.root)
744 .unwrap_or(&repo_path)
745 .to_path_buf();
746
747 let mut repo = Repository::new(repo_name, relative_path);
748
749 if let Ok(Some(url)) = get_remote_url(&repo_path) {
751 repo = repo.with_url(url);
752 }
753
754 if let Ok(Some(branch)) = get_current_branch(&repo_path) {
755 repo = repo.with_branch(branch);
756 }
757
758 self.config.add_repository(repo);
759 }
760 }
761
762 self.save_config().await?;
764
765 if let Err(e) = super::claude_agents::configure_claude_agents(&self.config).await {
767 warn!("Failed to configure Claude agents: {}", e);
768 }
769
770 println!(
771 "{} Initialized workspace '{}' in {}",
772 style("â").green().bold(),
773 style(&self.config.workspace.name).cyan().bold(),
774 style(self.config.workspace.root.display()).dim()
775 );
776
777 if auto_discover && !self.config.repositories.is_empty() {
778 println!(
779 "{} Auto-discovered {} repositories",
780 style("đ").green(),
781 self.config.repositories.len()
782 );
783 }
784
785 Ok(())
786 }
787
788 pub async fn edit_config(&self, direct: bool) -> Result<()> {
789 use std::process::Command;
790
791 let editor = std::env::var("EDITOR")
793 .or_else(|_| std::env::var("VISUAL"))
794 .unwrap_or_else(|_| {
795 if cfg!(target_os = "windows") {
796 "notepad".to_string()
797 } else {
798 "vi".to_string()
799 }
800 });
801
802 if !direct {
803 println!(
804 "{} Opening config file in {}...",
805 style("đ").blue(),
806 style(&editor).cyan()
807 );
808 }
809
810 let status = Command::new(&editor)
812 .arg(&self.config_path)
813 .status()
814 .with_context(|| format!("Failed to open editor: {editor}"))?;
815
816 if !status.success() {
817 anyhow::bail!("Editor exited with non-zero status");
818 }
819
820 println!(
821 "{} Configuration edited successfully",
822 style("â").green().bold()
823 );
824
825 Ok(())
826 }
827
828 pub async fn show_config(&self, format: &str, section: Option<&str>) -> Result<()> {
829 let output = match section {
830 Some("workspace") => match format {
831 "json" => serde_json::to_string_pretty(&self.config.workspace)?,
832 "pretty" => format!(
833 "đī¸ Workspace Configuration\n\
834 ââââââââââââââââââââââââ\n\
835 Name: {}\n\
836 Root: {}\n\
837 Auto-discover: {}",
838 style(&self.config.workspace.name).cyan(),
839 style(self.config.workspace.root.display()).dim(),
840 if self.config.workspace.auto_discover {
841 style("enabled").green()
842 } else {
843 style("disabled").red()
844 }
845 ),
846 _ => serde_yaml::to_string(&self.config.workspace)?,
847 },
848 Some("repositories") => match format {
849 "json" => serde_json::to_string_pretty(&self.config.repositories)?,
850 "pretty" => {
851 let mut output = format!(
852 "đ Repositories ({})\nâââââââââââââââââ",
853 self.config.repositories.len()
854 );
855 for repo in &self.config.repositories {
856 output.push_str(&format!(
857 "\n\nâĸ {}\n Path: {}\n URL: {}\n Branch: {}",
858 style(&repo.name).cyan().bold(),
859 style(repo.path.display()).dim(),
860 repo.url.as_deref().unwrap_or("(none)"),
861 repo.branch.as_deref().unwrap_or("(default)")
862 ));
863 }
864 output
865 }
866 _ => serde_yaml::to_string(&self.config.repositories)?,
867 },
868 Some("groups") => match format {
869 "json" => serde_json::to_string_pretty(&self.config.groups)?,
870 "pretty" => {
871 let mut output =
872 format!("đĨ Groups ({})\nââââââââââââ", self.config.groups.len());
873 for group in &self.config.groups {
874 output.push_str(&format!(
875 "\n\nâĸ {}\n Repositories: {}",
876 style(&group.name).cyan().bold(),
877 group.repos.join(", ")
878 ));
879 }
880 output
881 }
882 _ => serde_yaml::to_string(&self.config.groups)?,
883 },
884 Some("apps") => match format {
885 "json" => serde_json::to_string_pretty(&self.config.apps)?,
886 "pretty" => {
887 let mut output = "đ§ App Integrations\nâââââââââââââââââ".to_string();
888
889 if let Some(github) = &self.config.apps.github {
890 output.push_str(&format!(
891 "\n\nâĸ GitHub: {}\n Token source: {}",
892 if github.enabled {
893 style("enabled").green()
894 } else {
895 style("disabled").red()
896 },
897 github.token_source
898 ));
899 }
900
901 if let Some(warp) = &self.config.apps.warp {
902 output.push_str(&format!(
903 "\n\nâĸ Warp: {}\n Config dir: {}",
904 if warp.enabled {
905 style("enabled").green()
906 } else {
907 style("disabled").red()
908 },
909 warp.config_dir.display()
910 ));
911 }
912
913 if let Some(iterm2) = &self.config.apps.iterm2 {
914 output.push_str(&format!(
915 "\n\nâĸ iTerm2: {}\n Config dir: {}",
916 if iterm2.enabled {
917 style("enabled").green()
918 } else {
919 style("disabled").red()
920 },
921 iterm2.config_dir.display()
922 ));
923 }
924
925 if let Some(vscode) = &self.config.apps.vscode {
926 output.push_str(&format!(
927 "\n\nâĸ VSCode: {}\n Workspace dir: {}",
928 if vscode.enabled {
929 style("enabled").green()
930 } else {
931 style("disabled").red()
932 },
933 vscode.workspace_dir.display()
934 ));
935 }
936
937 output
938 }
939 _ => serde_yaml::to_string(&self.config.apps)?,
940 },
941 _ => match format {
942 "json" => serde_json::to_string_pretty(&self.config)?,
943 "pretty" => {
944 let mut output = String::new();
946
947 output.push_str(&format!(
949 "đī¸ Workspace Configuration\n\
950 ââââââââââââââââââââââââ\n\
951 Name: {}\n\
952 Root: {}\n\
953 Auto-discover: {}\n\n",
954 style(&self.config.workspace.name).cyan(),
955 style(self.config.workspace.root.display()).dim(),
956 if self.config.workspace.auto_discover {
957 style("enabled").green()
958 } else {
959 style("disabled").red()
960 }
961 ));
962
963 output.push_str(&format!(
965 "đ Repositories ({})\nâââââââââââââââââ",
966 self.config.repositories.len()
967 ));
968 for repo in &self.config.repositories {
969 output.push_str(&format!(
970 "\nâĸ {} ({})",
971 style(&repo.name).cyan(),
972 style(repo.path.display()).dim()
973 ));
974 }
975
976 if !self.config.groups.is_empty() {
978 output.push_str(&format!(
979 "\n\nđĨ Groups ({})\nâââââââââââ",
980 self.config.groups.len()
981 ));
982 for group in &self.config.groups {
983 output.push_str(&format!(
984 "\nâĸ {} ({} repos)",
985 style(&group.name).cyan(),
986 group.repos.len()
987 ));
988 }
989 }
990
991 output
992 }
993 _ => serde_yaml::to_string(&self.config)?,
994 },
995 };
996
997 println!("{output}");
998 Ok(())
999 }
1000
1001 pub async fn validate_config(
1002 &self,
1003 check_paths: bool,
1004 check_remotes: bool,
1005 check_apps: bool,
1006 ) -> Result<()> {
1007 let mut issues = Vec::new();
1008 let mut warnings = Vec::new();
1009
1010 println!(
1011 "{} Validating workspace configuration...",
1012 style("đ").blue()
1013 );
1014
1015 if !self.config.workspace.root.exists() {
1017 issues.push(format!(
1018 "Workspace root does not exist: {}",
1019 self.config.workspace.root.display()
1020 ));
1021 }
1022
1023 if check_paths {
1025 println!(" {} Checking repository paths...", style("â").dim());
1026 for repo in &self.config.repositories {
1027 let repo_path = self.config.workspace.root.join(&repo.path);
1028 if !repo_path.exists() {
1029 issues.push(format!(
1030 "Repository path does not exist: {} ({})",
1031 repo.name,
1032 repo_path.display()
1033 ));
1034 } else if !repo_path.join(".git").exists() {
1035 warnings.push(format!(
1036 "Path exists but is not a git repository: {} ({})",
1037 repo.name,
1038 repo_path.display()
1039 ));
1040 }
1041 }
1042 }
1043
1044 if check_remotes {
1046 println!(" {} Checking remote URLs...", style("â").dim());
1047 for repo in &self.config.repositories {
1048 if let Some(url) = &repo.url {
1049 if !url.starts_with("https://")
1051 && !url.starts_with("git@")
1052 && !url.starts_with("ssh://")
1053 {
1054 warnings.push(format!(
1055 "Unusual remote URL format for {}: {}",
1056 repo.name, url
1057 ));
1058 }
1059 }
1060 }
1061 }
1062
1063 if check_apps {
1065 println!(" {} Checking app integrations...", style("â").dim());
1066
1067 if let Some(warp) = &self.config.apps.warp {
1068 if warp.enabled && !warp.config_dir.exists() {
1069 warnings.push(format!(
1070 "Warp config directory does not exist: {}",
1071 warp.config_dir.display()
1072 ));
1073 }
1074 }
1075
1076 if let Some(iterm2) = &self.config.apps.iterm2 {
1077 if iterm2.enabled && !iterm2.config_dir.exists() {
1078 warnings.push(format!(
1079 "iTerm2 config directory does not exist: {}",
1080 iterm2.config_dir.display()
1081 ));
1082 }
1083 }
1084
1085 if let Some(vscode) = &self.config.apps.vscode {
1086 if vscode.enabled && !vscode.workspace_dir.exists() {
1087 warnings.push(format!(
1088 "VSCode workspace directory does not exist: {}",
1089 vscode.workspace_dir.display()
1090 ));
1091 }
1092 }
1093 }
1094
1095 for group in &self.config.groups {
1097 for repo_name in &group.repos {
1098 if !self
1099 .config
1100 .repositories
1101 .iter()
1102 .any(|r| &r.name == repo_name)
1103 {
1104 issues.push(format!(
1105 "Group '{}' references non-existent repository: {}",
1106 group.name, repo_name
1107 ));
1108 }
1109 }
1110 }
1111
1112 println!();
1114 if issues.is_empty() && warnings.is_empty() {
1115 println!("{} Configuration is valid!", style("â").green().bold());
1116 } else {
1117 if !issues.is_empty() {
1118 println!("{} Issues found:", style("â").red().bold());
1119 for issue in &issues {
1120 println!(" âĸ {issue}");
1121 }
1122 }
1123
1124 if !warnings.is_empty() {
1125 println!("\n{} Warnings:", style("â ī¸").yellow().bold());
1126 for warning in &warnings {
1127 println!(" âĸ {warning}");
1128 }
1129 }
1130
1131 if !issues.is_empty() {
1132 anyhow::bail!(
1133 "Configuration validation failed with {} issues",
1134 issues.len()
1135 );
1136 }
1137 }
1138
1139 Ok(())
1140 }
1141
1142 pub async fn configure_app_for_repo(
1146 &mut self,
1147 repo_name: &str,
1148 app: &str,
1149 template: &str,
1150 ) -> Result<()> {
1151 let repo = self
1152 .config
1153 .repositories
1154 .iter_mut()
1155 .find(|r| r.name == repo_name)
1156 .context("Repository not found")?;
1157
1158 let templates = self.template_manager.list_templates(app).await?;
1160 if !templates.contains(&template.to_string()) {
1161 anyhow::bail!("Template '{}' not found for app '{}'", template, app);
1162 }
1163
1164 repo.apps.insert(
1165 app.to_string(),
1166 AppConfig::WithTemplate {
1167 template: template.to_string(),
1168 },
1169 );
1170
1171 self.config.save_to_file(&self.config_path).await?;
1172
1173 Ok(())
1174 }
1175
1176 pub fn list_apps_for_repo(&self, repo_name: &str) -> Result<Vec<(String, String)>> {
1178 let repo = self
1179 .config
1180 .repositories
1181 .iter()
1182 .find(|r| r.name == repo_name)
1183 .context("Repository not found")?;
1184
1185 let mut apps = Vec::new();
1186 for (app_name, config) in &repo.apps {
1187 if let AppConfig::WithTemplate { template } = config {
1188 apps.push((app_name.clone(), template.clone()));
1189 } else if config.is_enabled() {
1190 apps.push((app_name.clone(), "default".to_string()));
1191 }
1192 }
1193
1194 Ok(apps)
1195 }
1196
1197 pub async fn get_repos_with_apps_and_status(&self) -> Result<Vec<RepoWithStatus>> {
1199 let mut repos_with_status = Vec::new();
1200
1201 for repo in &self.config.repositories {
1202 let apps = self.list_apps_for_repo(&repo.name)?;
1203
1204 if !apps.is_empty() {
1206 let repo_path = self.config.workspace.root.join(&repo.path);
1207 let git_status = get_git_status(&repo_path)
1208 .await
1209 .unwrap_or_else(|_| GitStatus {
1210 repository_name: repo.name.clone(),
1211 path: repo.path.display().to_string(),
1212 branch: None,
1213 clean: true,
1214 ahead: 0,
1215 behind: 0,
1216 staged: 0,
1217 unstaged: 0,
1218 untracked: 0,
1219 remote_url: None,
1220 });
1221
1222 let status_indicator = if git_status.clean {
1224 "â".to_string()
1225 } else {
1226 let mut indicators = Vec::new();
1227 if git_status.staged > 0 {
1228 indicators.push(format!("{}S", git_status.staged));
1229 }
1230 if git_status.unstaged > 0 {
1231 indicators.push(format!("{}U", git_status.unstaged));
1232 }
1233 if git_status.untracked > 0 {
1234 indicators.push(format!("{}?", git_status.untracked));
1235 }
1236 if git_status.ahead > 0 {
1237 indicators.push(format!("â{}", git_status.ahead));
1238 }
1239 if git_status.behind > 0 {
1240 indicators.push(format!("â{}", git_status.behind));
1241 }
1242 if indicators.is_empty() {
1243 "â".to_string()
1244 } else {
1245 indicators.join(" ")
1246 }
1247 };
1248
1249 let app_names: Vec<String> = apps.iter().map(|(name, _)| name.clone()).collect();
1250 let display_string = format!(
1251 "{} [{}] (apps: {})",
1252 repo.name,
1253 status_indicator,
1254 app_names.join(", ")
1255 );
1256
1257 repos_with_status.push(RepoWithStatus {
1258 name: repo.name.clone(),
1259 path: repo.path.display().to_string(),
1260 apps,
1261 git_status,
1262 display_string,
1263 });
1264 }
1265 }
1266
1267 Ok(repos_with_status)
1268 }
1269
1270 pub fn list_repos_with_app(&self, app: &str) -> Vec<(&Repository, String)> {
1272 let mut repos = Vec::new();
1273
1274 for repo in &self.config.repositories {
1275 if let Some(config) = repo.apps.get(app) {
1276 if config.is_enabled() {
1277 let template = match config {
1278 AppConfig::WithTemplate { template } => template.clone(),
1279 AppConfig::WithConfig { template, .. } => template.clone(),
1280 AppConfig::Enabled(_) => "default".to_string(),
1281 };
1282 repos.push((repo, template));
1283 }
1284 }
1285 }
1286
1287 repos
1288 }
1289
1290 pub async fn init_templates(&self) -> Result<()> {
1292 self.template_manager.init_default_templates().await?;
1293
1294 println!(
1295 "{} Initialized default templates in {}",
1296 style("â").green().bold(),
1297 style(super::constants::CONFIG_DIR_DISPLAY)
1298 .cyan()
1299 .to_string()
1300 + "/templates"
1301 );
1302
1303 Ok(())
1304 }
1305
1306 pub async fn list_templates(&self, app: &str) -> Result<Vec<String>> {
1308 self.template_manager.list_templates(app).await
1309 }
1310
1311 pub async fn create_template(
1313 &self,
1314 app: &str,
1315 template_name: &str,
1316 from_template: &str,
1317 ) -> Result<()> {
1318 let content = self
1319 .template_manager
1320 .load_template(app, from_template)
1321 .await?;
1322 self.template_manager
1323 .save_template(app, template_name, &content)
1324 .await?;
1325
1326 println!(
1327 "{} Created template '{}' for {}",
1328 style("â").green().bold(),
1329 style(template_name).cyan(),
1330 style(app).cyan()
1331 );
1332
1333 Ok(())
1334 }
1335
1336 pub async fn delete_template(&self, app: &str, template_name: &str) -> Result<()> {
1338 if template_name == "default" {
1339 anyhow::bail!("Cannot delete the default template");
1340 }
1341
1342 self.template_manager
1343 .delete_template(app, template_name)
1344 .await?;
1345
1346 println!(
1347 "{} Deleted template '{}' for {}",
1348 style("â").green().bold(),
1349 style(template_name).cyan(),
1350 style(app).cyan()
1351 );
1352
1353 Ok(())
1354 }
1355
1356 pub async fn show_app_configurations(&self) -> Result<()> {
1358 println!("\n{} App Configurations:", style("đą").blue());
1359 println!();
1360
1361 for repo in &self.config.repositories {
1362 if repo.apps.is_empty() {
1363 continue;
1364 }
1365
1366 println!("{} {}", style("â").dim(), style(&repo.name).cyan().bold());
1367 for (app_name, config) in &repo.apps {
1368 if config.is_enabled() {
1369 let template = match config {
1370 AppConfig::WithTemplate { template } => template.as_str(),
1371 AppConfig::WithConfig { template, .. } => template.as_str(),
1372 AppConfig::Enabled(_) => "default",
1373 };
1374 println!(
1375 " {} {} (template: {})",
1376 style("âĸ").dim(),
1377 style(app_name).green(),
1378 style(template).yellow()
1379 );
1380 }
1381 }
1382 println!();
1383 }
1384 Ok(())
1385 }
1386
1387 pub async fn get_default_template(&self, app: &str) -> Result<String> {
1389 match self.template_manager.load_template(app, "default").await {
1391 Ok(content) => Ok(content),
1392 Err(_) => {
1393 let default_content = match app {
1395 "warp" => crate::workspace::templates::DEFAULT_WARP_TEMPLATE,
1396 "iterm2" => crate::workspace::templates::DEFAULT_ITERM2_TEMPLATE,
1397 "wezterm" => crate::workspace::templates::DEFAULT_WEZTERM_TEMPLATE,
1398 "vscode" => crate::workspace::templates::DEFAULT_VSCODE_TEMPLATE,
1399 "cursor" => crate::workspace::templates::DEFAULT_CURSOR_TEMPLATE,
1400 "windsurf" => crate::workspace::templates::DEFAULT_WINDSURF_TEMPLATE,
1401 _ => anyhow::bail!("Unknown app '{}' and no default template found", app),
1402 };
1403 Ok(default_content.to_string())
1404 }
1405 }
1406 }
1407
1408 pub async fn save_template(&self, app: &str, name: &str, content: &str) -> Result<()> {
1410 self.template_manager
1411 .save_template(app, name, content)
1412 .await
1413 }
1414
1415 pub async fn update_default_templates(&self, apps: Vec<String>) -> Result<()> {
1417 for app in apps {
1418 let default_content = match app.as_str() {
1419 "warp" => crate::workspace::templates::DEFAULT_WARP_TEMPLATE,
1420 "iterm2" => crate::workspace::templates::DEFAULT_ITERM2_TEMPLATE,
1421 "wezterm" => crate::workspace::templates::DEFAULT_WEZTERM_TEMPLATE,
1422 "vscode" => crate::workspace::templates::DEFAULT_VSCODE_TEMPLATE,
1423 "cursor" => crate::workspace::templates::DEFAULT_CURSOR_TEMPLATE,
1424 "windsurf" => crate::workspace::templates::DEFAULT_WINDSURF_TEMPLATE,
1425 _ => {
1426 println!("{} Unknown app '{}', skipping", style("â ī¸").yellow(), app);
1427 continue;
1428 }
1429 };
1430
1431 self.template_manager
1432 .save_template(&app, "default", default_content)
1433 .await?;
1434 println!(
1435 "{} Updated default template for {}",
1436 style("â").green(),
1437 style(&app).cyan()
1438 );
1439 }
1440
1441 Ok(())
1442 }
1443
1444 pub async fn smart_open_repository(&self, repo_name: &str) -> Result<()> {
1446 let repo = self
1447 .config
1448 .repositories
1449 .iter()
1450 .find(|r| r.name == repo_name)
1451 .context("Repository not found")?;
1452
1453 let configured_apps: Vec<String> = repo.apps.keys().cloned().collect();
1455
1456 let available_apps = self.get_available_apps().await;
1458
1459 let app_choices = self.build_app_choice_menu(&configured_apps, &available_apps);
1461
1462 if app_choices.is_empty() {
1463 anyhow::bail!("No compatible apps found on this system");
1464 }
1465
1466 let selected_app = self.prompt_app_selection(&app_choices)?;
1468
1469 self.open_repo_with_app_options(repo_name, &selected_app, false)
1471 .await
1472 }
1473
1474 pub async fn open_repo_with_app(&self, repo_name: &str, app: &str) -> Result<()> {
1476 self.open_repo_with_app_options(repo_name, app, false).await
1477 }
1478
1479 pub async fn open_repo_with_app_options(
1481 &self,
1482 repo_name: &str,
1483 app: &str,
1484 no_itermocil: bool,
1485 ) -> Result<()> {
1486 let repo = self
1487 .config
1488 .repositories
1489 .iter()
1490 .find(|r| r.name == repo_name)
1491 .context("Repository not found")?;
1492
1493 if repo.is_app_enabled(app) {
1495 self.open_repo_with_configured_app(repo, app, no_itermocil)
1497 .await
1498 } else {
1499 self.open_repo_with_basic_app(repo, app).await
1501 }
1502 }
1503
1504 async fn open_repo_with_configured_app(
1506 &self,
1507 repo: &Repository,
1508 app: &str,
1509 no_itermocil: bool,
1510 ) -> Result<()> {
1511 match app {
1512 "warp" => {
1513 crate::apps::open_with_warp(&self.config, repo, &self.template_manager).await?;
1514 }
1515 "iterm2" => {
1516 crate::apps::open_with_iterm2_options(
1517 &self.config,
1518 repo,
1519 &self.template_manager,
1520 no_itermocil,
1521 )
1522 .await?;
1523 }
1524 "wezterm" => {
1525 crate::apps::open_with_wezterm_options(
1526 &self.config,
1527 repo,
1528 &self.template_manager,
1529 no_itermocil,
1530 )
1531 .await?;
1532 }
1533 "vscode" => {
1534 crate::apps::open_with_vscode(&self.config, repo, &self.template_manager).await?;
1535 }
1536 "cursor" => {
1537 crate::apps::open_with_cursor(&self.config, repo, &self.template_manager).await?;
1538 }
1539 "windsurf" => {
1540 crate::apps::open_with_windsurf(&self.config, repo, &self.template_manager).await?;
1541 }
1542 _ => {
1543 anyhow::bail!("Unknown app: {}", app);
1544 }
1545 }
1546
1547 Ok(())
1548 }
1549
1550 async fn open_repo_with_basic_app(&self, repo: &Repository, app: &str) -> Result<()> {
1552 let repo_path = self.config.workspace.root.join(&repo.path);
1554
1555 if !self.is_app_available(app).await {
1557 anyhow::bail!("App '{}' is not available on this system", app);
1558 }
1559
1560 println!(
1561 "{} Opening {} with {} (basic mode - no custom templates)",
1562 style("đ").blue(),
1563 style(&repo.name).cyan(),
1564 style(app).blue()
1565 );
1566
1567 match app {
1568 "vscode" => {
1569 let status = std::process::Command::new("code")
1571 .arg(&repo_path)
1572 .status()
1573 .context("Failed to execute VS Code")?;
1574
1575 if !status.success() {
1576 anyhow::bail!("VS Code failed to open repository");
1577 }
1578 }
1579 "cursor" => {
1580 let status = std::process::Command::new("cursor")
1582 .arg(&repo_path)
1583 .status()
1584 .context("Failed to execute Cursor")?;
1585
1586 if !status.success() {
1587 anyhow::bail!("Cursor failed to open repository");
1588 }
1589 }
1590 "windsurf" => {
1591 let status = std::process::Command::new("windsurf")
1593 .arg(&repo_path)
1594 .status()
1595 .context("Failed to execute Windsurf")?;
1596
1597 if !status.success() {
1598 anyhow::bail!("Windsurf failed to open repository");
1599 }
1600 }
1601 "warp" => {
1602 let status = std::process::Command::new("open")
1604 .args(["-a", "Warp", &format!("--args cd {}", repo_path.display())])
1605 .status()
1606 .context("Failed to execute Warp")?;
1607
1608 if !status.success() {
1609 anyhow::bail!("Warp failed to open repository");
1610 }
1611 }
1612 "iterm2" => {
1613 let applescript = format!(
1615 r#"tell application "iTerm2"
1616 activate
1617 tell current window
1618 create tab with default profile
1619 tell current session
1620 write text "cd '{}'"
1621 end tell
1622 end tell
1623 end tell"#,
1624 repo_path.display()
1625 );
1626
1627 let status = std::process::Command::new("osascript")
1628 .args(["-e", &applescript])
1629 .status()
1630 .context("Failed to execute iTerm2 AppleScript")?;
1631
1632 if !status.success() {
1633 anyhow::bail!("iTerm2 failed to open repository");
1634 }
1635 }
1636 "wezterm" => {
1637 let status = std::process::Command::new("wezterm")
1639 .args(["cli", "spawn", "--cwd", &repo_path.to_string_lossy()])
1640 .status()
1641 .context("Failed to execute WezTerm")?;
1642
1643 if !status.success() {
1644 anyhow::bail!("WezTerm failed to open repository");
1645 }
1646 }
1647 _ => {
1648 anyhow::bail!("Unknown app: {}", app);
1649 }
1650 }
1651
1652 println!(
1653 "{} Successfully opened {} with {}",
1654 style("â").green(),
1655 style(&repo.name).cyan(),
1656 style(app).blue()
1657 );
1658
1659 Ok(())
1660 }
1661
1662 pub fn get_current_app_states(&self, repo_name: &str) -> Result<AppConfigState> {
1664 let repo = self
1665 .config
1666 .repositories
1667 .iter()
1668 .find(|r| r.name == repo_name)
1669 .context("Repository not found")?;
1670
1671 let mut state = AppConfigState::default();
1672
1673 for (app_name, config) in &repo.apps {
1674 if config.is_enabled() {
1675 let template = match config {
1676 AppConfig::WithTemplate { template } => template.clone(),
1677 AppConfig::WithConfig { template, .. } => template.clone(),
1678 AppConfig::Enabled(_) => "default".to_string(),
1679 };
1680
1681 match app_name.as_str() {
1682 "warp" => state.warp = Some(template),
1683 "iterm2" => state.iterm2 = Some(template),
1684 "wezterm" => state.wezterm = Some(template),
1685 "vscode" => state.vscode = Some(template),
1686 "cursor" => state.cursor = Some(template),
1687 "windsurf" => state.windsurf = Some(template),
1688 _ => {} }
1690 }
1691 }
1692
1693 Ok(state)
1694 }
1695
1696 pub async fn remove_app_for_repo(&mut self, repo_name: &str, app: &str) -> Result<()> {
1698 let repo = self
1699 .config
1700 .repositories
1701 .iter_mut()
1702 .find(|r| r.name == repo_name)
1703 .context("Repository not found")?;
1704
1705 repo.apps.remove(app);
1706 self.config.save_to_file(&self.config_path).await?;
1707
1708 Ok(())
1709 }
1710
1711 pub async fn cleanup_app_files(&self, repo_name: &str, app: &str) -> Result<()> {
1713 let repo = self
1714 .config
1715 .repositories
1716 .iter()
1717 .find(|r| r.name == repo_name)
1718 .context("Repository not found")?;
1719
1720 match app {
1721 "warp" => {
1722 crate::apps::cleanup_warp_config(&self.config, repo).await?;
1723 }
1724 "iterm2" => {
1725 crate::apps::cleanup_iterm2_config(&self.config, repo).await?;
1726 }
1727 "wezterm" => {
1728 crate::apps::cleanup_wezterm_config(&self.config, repo).await?;
1729 }
1730 "vscode" => {
1731 crate::apps::cleanup_vscode_config(&self.config, repo).await?;
1732 }
1733 "cursor" => {
1734 crate::apps::cleanup_cursor_config(&self.config, repo).await?;
1735 }
1736 "windsurf" => {
1737 crate::apps::cleanup_windsurf_config(&self.config, repo).await?;
1738 }
1739 _ => {
1740 warn!("Unknown app '{}' for cleanup", app);
1741 }
1742 }
1743
1744 Ok(())
1745 }
1746
1747 pub async fn configure_multiple_apps(
1749 &mut self,
1750 repo_name: &str,
1751 app_selections: Vec<AppSelection>,
1752 ) -> Result<Vec<String>> {
1753 let mut changes = Vec::new();
1754
1755 let current_state = self.get_current_app_states(repo_name)?;
1757 let current_apps = [
1758 ("warp", current_state.warp.as_ref()),
1759 ("iterm2", current_state.iterm2.as_ref()),
1760 ("vscode", current_state.vscode.as_ref()),
1761 ("cursor", current_state.cursor.as_ref()),
1762 ("windsurf", current_state.windsurf.as_ref()),
1763 ];
1764
1765 for selection in app_selections {
1767 let currently_configured = current_apps
1768 .iter()
1769 .find(|(app, _)| *app == selection.app)
1770 .map(|(_, template)| template.is_some())
1771 .unwrap_or(false);
1772
1773 if selection.selected && !currently_configured {
1774 let template = selection.template.as_deref().unwrap_or("default");
1776 self.configure_app_for_repo(repo_name, &selection.app, template)
1777 .await?;
1778 changes.push(format!(
1779 "â
Configured {} with template '{}'",
1780 selection.app, template
1781 ));
1782 } else if selection.selected && currently_configured {
1783 let current_template = current_apps
1785 .iter()
1786 .find(|(app, _)| *app == selection.app)
1787 .and_then(|(_, template)| template.as_ref())
1788 .map(|s| s.as_str())
1789 .unwrap_or("default");
1790
1791 let new_template = selection.template.as_deref().unwrap_or("default");
1792 if current_template != new_template {
1793 self.configure_app_for_repo(repo_name, &selection.app, new_template)
1794 .await?;
1795 changes.push(format!(
1796 "đ Updated {} template to '{}'",
1797 selection.app, new_template
1798 ));
1799 }
1800 } else if !selection.selected && currently_configured {
1801 self.cleanup_app_files(repo_name, &selection.app).await?;
1803 self.remove_app_for_repo(repo_name, &selection.app).await?;
1804 changes.push(format!("đī¸ Removed {} configuration", selection.app));
1805 }
1806 }
1807
1808 Ok(changes)
1809 }
1810
1811 async fn discover_all_config_files(&self) -> Result<Vec<PathBuf>> {
1813 let mut config_files = Vec::new();
1814
1815 if self.config_path.exists() {
1817 config_files.push(self.config_path.clone());
1818 }
1819
1820 let vibe_dir = super::constants::get_config_dir();
1821
1822 let state_file = vibe_dir.join("state.json");
1824 if state_file.exists() {
1825 config_files.push(state_file);
1826 }
1827
1828 let templates_dir = vibe_dir.join("templates");
1830 if templates_dir.exists() {
1831 config_files.push(templates_dir);
1832 }
1833
1834 for repo in &self.config.repositories {
1836 if repo.apps.is_empty() {
1838 continue;
1839 }
1840
1841 for app in repo.apps.keys() {
1842 match app.as_str() {
1843 "warp" => {
1844 if let Some(warp_integration) = &self.config.apps.warp {
1845 let config_name =
1846 format!("vibe-{}-{}.yaml", self.config.workspace.name, repo.name);
1847 let config_path = warp_integration.config_dir.join(&config_name);
1848 if config_path.exists() {
1849 config_files.push(config_path);
1850 }
1851 }
1852 }
1853 "iterm2" => {
1854 if let Some(iterm2_integration) = &self.config.apps.iterm2 {
1855 let config_name =
1856 format!("vibe-{}-{}.json", self.config.workspace.name, repo.name);
1857 let config_path = iterm2_integration.config_dir.join(&config_name);
1858 if config_path.exists() {
1859 config_files.push(config_path);
1860 }
1861 }
1862 }
1863 "wezterm" => {
1864 if let Some(wezterm_integration) = &self.config.apps.wezterm {
1865 let config_name =
1866 format!("vibe-{}-{}.lua", self.config.workspace.name, repo.name);
1867 let config_path = wezterm_integration.config_dir.join(&config_name);
1868 if config_path.exists() {
1869 config_files.push(config_path);
1870 }
1871 }
1872 }
1873 "vscode" => {
1874 if let Some(vscode_integration) = &self.config.apps.vscode {
1875 let config_name = format!(
1876 "vibe-{}-{}.code-workspace",
1877 self.config.workspace.name, repo.name
1878 );
1879 let config_path = vscode_integration.workspace_dir.join(&config_name);
1880 if config_path.exists() {
1881 config_files.push(config_path);
1882 }
1883 }
1884 }
1885 _ => {}
1886 }
1887 }
1888 }
1889
1890 Ok(config_files)
1891 }
1892
1893 async fn cleanup_all_app_configs(&self) -> Result<()> {
1895 for repo in &self.config.repositories {
1896 for app in repo.apps.keys() {
1897 if let Err(e) = self.cleanup_app_files(&repo.name, app).await {
1898 warn!("Failed to cleanup {} config for {}: {}", app, repo.name, e);
1899 }
1900 }
1901 }
1902 Ok(())
1903 }
1904
1905 pub async fn create_backup(
1907 &self,
1908 output_dir: Option<PathBuf>,
1909 custom_name: Option<String>,
1910 ) -> Result<PathBuf> {
1911 use chrono::Utc;
1912 use std::process::Command;
1913
1914 let backup_dir = output_dir.unwrap_or_else(|| {
1916 dirs::home_dir()
1917 .unwrap_or_else(|| PathBuf::from("."))
1918 .join(super::constants::CONFIG_DIR_PATH)
1919 .join("backups")
1920 });
1921
1922 tokio::fs::create_dir_all(&backup_dir)
1924 .await
1925 .with_context(|| {
1926 format!(
1927 "Failed to create backup directory: {}",
1928 backup_dir.display()
1929 )
1930 })?;
1931
1932 let timestamp = Utc::now().format("%Y%m%d-%H%M%S");
1934 let backup_name = custom_name.unwrap_or_else(|| format!("vibe-backup-{timestamp}"));
1935 let backup_filename = format!("{backup_name}.tgz");
1936 let backup_path = backup_dir.join(&backup_filename);
1937
1938 println!("{} Creating backup archive...", style("đĻ").blue());
1939
1940 let config_files = self.discover_all_config_files().await?;
1942
1943 if config_files.is_empty() {
1944 println!(
1945 "{} No configuration files found to backup",
1946 style("â ī¸").yellow()
1947 );
1948 return Ok(backup_path);
1949 }
1950
1951 let temp_dir = tempfile::tempdir().context("Failed to create temporary directory")?;
1953 let temp_path = temp_dir.path();
1954
1955 for config_file in &config_files {
1957 let file_name = config_file
1958 .file_name()
1959 .context("Invalid file name")?
1960 .to_string_lossy();
1961
1962 if config_file == &self.config_path {
1963 let dest_path = temp_path.join("config.yaml");
1965 tokio::fs::copy(config_file, &dest_path)
1966 .await
1967 .with_context(|| format!("Failed to copy {}", config_file.display()))?;
1968 } else if file_name == "state.json" {
1969 let dest_path = temp_path.join("state.json");
1971 tokio::fs::copy(config_file, &dest_path)
1972 .await
1973 .with_context(|| format!("Failed to copy {}", config_file.display()))?;
1974 } else if config_file.to_string_lossy().contains("templates") {
1975 let dest_dir = temp_path.join("templates");
1977 tokio::fs::create_dir_all(&dest_dir).await?;
1978 copy_dir_recursive(config_file, &dest_dir)?;
1979 } else {
1980 let app_type = if file_name.ends_with(".yaml") {
1982 "warp"
1983 } else if file_name.ends_with(".json") && file_name != "state.json" {
1984 "iterm2"
1985 } else if file_name.ends_with(".lua") {
1986 "wezterm"
1987 } else if file_name.ends_with(".code-workspace") {
1988 "vscode"
1989 } else {
1990 "other"
1991 };
1992
1993 let app_dir = temp_path.join("app-configs").join(app_type);
1994 tokio::fs::create_dir_all(&app_dir).await?;
1995 let dest_path = app_dir.join(file_name.as_ref());
1996 tokio::fs::copy(config_file, &dest_path)
1997 .await
1998 .with_context(|| format!("Failed to copy {}", config_file.display()))?;
1999 }
2000 }
2001
2002 let tar_output = Command::new("tar")
2004 .args(["-czf"])
2005 .arg(&backup_path)
2006 .args(["-C"])
2007 .arg(temp_path)
2008 .arg(".")
2009 .output()
2010 .context("Failed to execute tar command")?;
2011
2012 if !tar_output.status.success() {
2013 let error_msg = String::from_utf8_lossy(&tar_output.stderr);
2014 anyhow::bail!("Tar command failed: {}", error_msg);
2015 }
2016
2017 println!(
2018 "{} Backup contains {} configuration files:",
2019 style("đ").green(),
2020 config_files.len()
2021 );
2022 for file in &config_files {
2023 println!(" {} {}", style("â").dim(), style(file.display()).cyan());
2024 }
2025
2026 Ok(backup_path)
2027 }
2028
2029 pub async fn factory_reset(&mut self, force: bool) -> Result<()> {
2031 self.factory_reset_with_options(force, false).await
2032 }
2033
2034 pub async fn reset_repositories(&mut self, force: bool) -> Result<()> {
2036 let repo_count = self.config.repositories.len();
2037
2038 if repo_count == 0 {
2039 println!("{} No repositories to reset", style("âšī¸").blue());
2040 return Ok(());
2041 }
2042
2043 if !force {
2044 println!(
2045 "{} This will remove all {} tracked repositories from your configuration",
2046 style("â ī¸").yellow(),
2047 style(repo_count).bold()
2048 );
2049 println!(
2050 "{} This will NOT delete the actual repository folders",
2051 style("âšī¸").blue()
2052 );
2053 println!();
2054
2055 println!(
2057 "{} Repositories to be removed from config:",
2058 style("đ").blue()
2059 );
2060 for repo in &self.config.repositories {
2061 println!(
2062 " {} {} ({})",
2063 style("â").dim(),
2064 style(&repo.name).cyan(),
2065 style(repo.path.display()).dim()
2066 );
2067 }
2068 println!();
2069
2070 use inquire::Confirm;
2071 let confirm = Confirm::new("Continue with repository reset?")
2072 .with_default(false)
2073 .prompt()
2074 .context("Failed to get user confirmation")?;
2075
2076 if !confirm {
2077 println!("{} Repository reset cancelled", style("â").green());
2078 return Ok(());
2079 }
2080 }
2081
2082 self.config.repositories.clear();
2084
2085 self.config
2087 .save_to_file(&self.config_path)
2088 .await
2089 .context("Failed to save updated configuration")?;
2090
2091 println!(
2092 "{} Cleared {} repositories from configuration",
2093 style("â
").green().bold(),
2094 style(repo_count).bold()
2095 );
2096 println!(
2097 "{} Use 'vibe git scan --import' to re-discover repositories",
2098 style("đĄ").blue()
2099 );
2100
2101 Ok(())
2102 }
2103
2104 pub async fn factory_reset_with_options(
2105 &mut self,
2106 force: bool,
2107 skip_final_confirmation: bool,
2108 ) -> Result<()> {
2109 if !force {
2110 display_println!(
2112 "{} {}",
2113 style("â ī¸ WARNING").red().bold(),
2114 style("This will permanently delete ALL vibe-workspace configuration!").red()
2115 );
2116 display_println!();
2117
2118 let config_files = self.discover_all_config_files().await?;
2120
2121 if !config_files.is_empty() {
2122 display_println!("{} The following files will be deleted:", style("đī¸").red());
2123 for file in &config_files {
2124 display_println!(" {} {}", style("Ã").red(), style(file.display()).dim());
2125 }
2126 display_println!();
2127 }
2128
2129 use inquire::Text;
2131 let confirmation = Text::new("Type 'reset my vibe' to confirm factory reset:")
2132 .prompt()
2133 .context("Failed to get user confirmation")?;
2134
2135 if confirmation != "reset my vibe" {
2136 display_println!(
2137 "{} Vibe Check: make sure you're ready for irreversable change and try again",
2138 style("đ").yellow()
2139 );
2140 return Ok(());
2141 }
2142
2143 if !skip_final_confirmation {
2145 use inquire::Confirm;
2146 let final_confirm = Confirm::new("Are you absolutely sure? This cannot be undone.")
2147 .with_default(false)
2148 .prompt()
2149 .context("Failed to get final confirmation")?;
2150
2151 if !final_confirm {
2152 display_println!("{} Vibe Check: make sure you're ready for irreversable change and try again", style("đ").yellow());
2153 return Ok(());
2154 }
2155 }
2156 }
2157
2158 display_println!("{} Performing factory reset...", style("đ").blue());
2159
2160 self.cleanup_all_app_configs().await?;
2162
2163 if self.config_path.exists() {
2165 tokio::fs::remove_file(&self.config_path)
2166 .await
2167 .with_context(|| {
2168 format!(
2169 "Failed to remove config file: {}",
2170 self.config_path.display()
2171 )
2172 })?;
2173 display_println!("{} Removed main configuration file", style("â").green());
2174 }
2175
2176 let vibe_dir = super::constants::get_config_dir();
2178 let templates_dir = vibe_dir.join("templates");
2179 if templates_dir.exists() {
2180 tokio::fs::remove_dir_all(&templates_dir)
2181 .await
2182 .with_context(|| {
2183 format!(
2184 "Failed to remove templates directory: {}",
2185 templates_dir.display()
2186 )
2187 })?;
2188 display_println!("{} Removed templates directory", style("â").green());
2189 }
2190
2191 let cache_dir = vibe_dir.join("cache");
2193 if cache_dir.exists() {
2194 tokio::fs::remove_dir_all(&cache_dir)
2195 .await
2196 .with_context(|| {
2197 format!("Failed to remove cache directory: {}", cache_dir.display())
2198 })?;
2199 display_println!("{} Removed cache directory", style("â").green());
2200 }
2201
2202 let state_file = vibe_dir.join("state.json");
2204 if state_file.exists() {
2205 tokio::fs::remove_file(&state_file).await.with_context(|| {
2206 format!("Failed to remove state file: {}", state_file.display())
2207 })?;
2208 display_println!("{} Removed state file", style("â").green());
2209 }
2210
2211 display_println!("{} Factory reset completed", style("â
").green().bold());
2212 display_println!();
2213 display_println!(
2214 "{} All vibe configuration has been cleared.",
2215 style("âšī¸").blue()
2216 );
2217 display_println!(
2218 "{} Run 'vibe' again to start the setup wizard.",
2219 style("đĄ").yellow()
2220 );
2221
2222 Ok(())
2223 }
2224
2225 pub fn get_repository(&self, name: &str) -> Option<&Repository> {
2227 self.config.get_repository(name)
2228 }
2229
2230 pub fn get_repository_flexible(&self, name: &str) -> Option<&Repository> {
2232 self.config.get_repository_flexible(name)
2233 }
2234
2235 pub fn list_repositories(&self) -> &[Repository] {
2237 &self.config.repositories
2238 }
2239
2240 pub async fn remove_repository(&mut self, name: &str) -> Result<()> {
2242 self.config.repositories.retain(|r| r.name != name);
2243 self.save_config().await?;
2244 Ok(())
2245 }
2246
2247 pub async fn is_app_available(&self, app_name: &str) -> bool {
2249 match app_name {
2250 "vscode" => {
2251 tokio::process::Command::new("code")
2253 .arg("--version")
2254 .output()
2255 .await
2256 .map(|output| output.status.success())
2257 .unwrap_or(false)
2258 }
2259 "warp" => {
2260 #[cfg(target_os = "macos")]
2262 {
2263 tokio::fs::metadata("/Applications/Warp.app").await.is_ok()
2264 }
2265 #[cfg(not(target_os = "macos"))]
2266 {
2267 false
2268 }
2269 }
2270 "iterm2" => {
2271 #[cfg(target_os = "macos")]
2273 {
2274 tokio::fs::metadata("/Applications/iTerm.app").await.is_ok()
2275 }
2276 #[cfg(not(target_os = "macos"))]
2277 {
2278 false
2279 }
2280 }
2281 "wezterm" => {
2282 tokio::process::Command::new("wezterm")
2284 .arg("--version")
2285 .output()
2286 .await
2287 .map(|output| output.status.success())
2288 .unwrap_or(false)
2289 }
2290 "cursor" => {
2291 #[cfg(target_os = "macos")]
2293 {
2294 tokio::fs::metadata("/Applications/Cursor.app")
2295 .await
2296 .is_ok()
2297 }
2298 #[cfg(not(target_os = "macos"))]
2299 {
2300 tokio::process::Command::new("cursor")
2302 .arg("--version")
2303 .output()
2304 .await
2305 .map(|output| output.status.success())
2306 .unwrap_or(false)
2307 }
2308 }
2309 "windsurf" => {
2310 #[cfg(target_os = "macos")]
2312 {
2313 tokio::fs::metadata("/Applications/Windsurf.app")
2314 .await
2315 .is_ok()
2316 }
2317 #[cfg(not(target_os = "macos"))]
2318 {
2319 tokio::process::Command::new("windsurf")
2321 .arg("--version")
2322 .output()
2323 .await
2324 .map(|output| output.status.success())
2325 .unwrap_or(false)
2326 }
2327 }
2328 _ => false,
2329 }
2330 }
2331
2332 pub async fn get_available_apps(&self) -> Vec<String> {
2334 let potential_apps = vec!["vscode", "cursor", "windsurf", "warp", "iterm2", "wezterm"];
2335 let mut available_apps = Vec::new();
2336
2337 for app in potential_apps {
2338 if self.is_app_available(app).await {
2339 available_apps.push(app.to_string());
2340 }
2341 }
2342
2343 available_apps
2344 }
2345
2346 fn build_app_choice_menu(
2348 &self,
2349 configured_apps: &[String],
2350 available_apps: &[String],
2351 ) -> Vec<AppChoice> {
2352 let mut choices = Vec::new();
2353
2354 for app in configured_apps {
2356 choices.push(AppChoice {
2357 app: app.clone(),
2358 display: format!("â
{} (configured with templates)", app),
2359 is_configured: true,
2360 });
2361 }
2362
2363 for app in available_apps {
2365 if !configured_apps.contains(app) {
2366 choices.push(AppChoice {
2367 app: app.clone(),
2368 display: format!("đ {} (basic mode)", app),
2369 is_configured: false,
2370 });
2371 }
2372 }
2373
2374 choices
2375 }
2376
2377 fn prompt_app_selection(&self, app_choices: &[AppChoice]) -> Result<String> {
2379 use inquire::Select;
2380
2381 let options: Vec<String> = app_choices
2382 .iter()
2383 .map(|choice| choice.display.clone())
2384 .collect();
2385
2386 let selected_display = Select::new("Choose app to open repository:", options)
2387 .with_help_message("Configured apps include templates and automation")
2388 .prompt()
2389 .map_err(|e| anyhow::anyhow!("App selection cancelled: {}", e))?;
2390
2391 for choice in app_choices {
2393 if choice.display == selected_display {
2394 return Ok(choice.app.clone());
2395 }
2396 }
2397
2398 anyhow::bail!("Invalid app selection")
2399 }
2400
2401 async fn init_repository_cache(cache_dir: &Path) -> Result<RepositoryCache> {
2405 tokio::fs::create_dir_all(cache_dir).await?;
2406 let repo_cache = RepositoryCache::new(cache_dir.join("repositories.db"));
2407 repo_cache.initialize().await?;
2408 Ok(repo_cache)
2409 }
2410
2411 async fn init_git_status_cache(cache_dir: &Path) -> Result<GitStatusCache> {
2413 tokio::fs::create_dir_all(cache_dir).await?;
2414 let git_cache = GitStatusCache::new(cache_dir.join("git_status.db"));
2415 git_cache.initialize().await?;
2416 Ok(git_cache)
2417 }
2418
2419 pub async fn get_repository_cache(&mut self) -> Result<&RepositoryCache> {
2421 if self.repo_cache.is_none() {
2422 let vibe_dir = super::constants::get_config_dir();
2423 let cache_dir = vibe_dir.join("cache");
2424 self.repo_cache = Some(Self::init_repository_cache(&cache_dir).await?);
2425 }
2426 Ok(self.repo_cache.as_ref().unwrap())
2427 }
2428
2429 pub async fn get_git_status_cache(&mut self) -> Result<&GitStatusCache> {
2431 if self.git_cache.is_none() {
2432 let vibe_dir = super::constants::get_config_dir();
2433 let cache_dir = vibe_dir.join("cache");
2434 self.git_cache = Some(Self::init_git_status_cache(&cache_dir).await?);
2435 }
2436 Ok(self.git_cache.as_ref().unwrap())
2437 }
2438
2439 pub async fn refresh_repository_cache(&mut self) -> Result<()> {
2441 let repositories = self.config.repositories.clone();
2443 let workspace_root = self.config.workspace.root.clone();
2444 let current_names: Vec<String> = repositories.iter().map(|r| r.name.clone()).collect();
2445
2446 if let Ok(cache) = self.get_repository_cache().await {
2447 cache
2448 .refresh_from_config(&repositories, &workspace_root)
2449 .await?;
2450 cache.cleanup_stale_entries(¤t_names).await?;
2451 }
2452 Ok(())
2453 }
2454
2455 pub async fn update_git_status_cache(&mut self, repo_names: &[String]) -> Result<()> {
2457 let repositories = self.config.repositories.clone();
2459 let workspace_root = self.config.workspace.root.clone();
2460
2461 if let Ok(cache) = self.get_git_status_cache().await {
2462 for repo_name in repo_names {
2463 if let Some(repo_config) = repositories.iter().find(|r| r.name == *repo_name) {
2464 let repo_path = workspace_root.join(&repo_config.path);
2465
2466 match get_git_status(&repo_path).await {
2467 Ok(git_status) => {
2468 let cached_status = git_status.into();
2469 if let Err(e) = cache.cache_git_status(&cached_status).await {
2470 warn!("Failed to cache git status for {}: {}", repo_name, e);
2471 }
2472 }
2473 Err(e) => {
2474 warn!("Failed to get git status for {}: {}", repo_name, e);
2475 }
2476 }
2477 }
2478 }
2479 }
2480 Ok(())
2481 }
2482
2483 pub async fn get_quick_launcher(&self) -> Result<crate::ui::quick_launcher::QuickLauncher> {
2485 let vibe_dir = super::constants::get_config_dir();
2486 let cache_dir = vibe_dir.join("cache");
2487 crate::ui::quick_launcher::QuickLauncher::new(&cache_dir).await
2488 }
2489
2490 pub fn get_main_menu_page_size(&self) -> usize {
2494 self.config
2495 .preferences
2496 .as_ref()
2497 .map(|p| p.page_sizes.main_menu)
2498 .unwrap_or(15)
2499 }
2500
2501 pub fn get_repository_list_page_size(&self) -> usize {
2503 self.config
2504 .preferences
2505 .as_ref()
2506 .map(|p| p.page_sizes.repository_list)
2507 .unwrap_or(15)
2508 }
2509
2510 pub fn get_quick_launch_page_size(&self) -> usize {
2512 self.config
2513 .preferences
2514 .as_ref()
2515 .map(|p| p.page_sizes.quick_launch)
2516 .unwrap_or(9)
2517 }
2518
2519 pub fn get_app_selection_page_size(&self) -> usize {
2521 self.config
2522 .preferences
2523 .as_ref()
2524 .map(|p| p.page_sizes.app_selection)
2525 .unwrap_or(10)
2526 }
2527
2528 pub fn get_git_search_results_page_size(&self) -> usize {
2530 self.config
2531 .preferences
2532 .as_ref()
2533 .map(|p| p.page_sizes.git_search_results)
2534 .unwrap_or(15)
2535 }
2536
2537 pub fn get_management_menus_page_size(&self) -> usize {
2539 self.config
2540 .preferences
2541 .as_ref()
2542 .map(|p| p.page_sizes.management_menus)
2543 .unwrap_or(10)
2544 }
2545
2546 pub fn get_app_installer_page_size(&self) -> usize {
2548 self.config
2549 .preferences
2550 .as_ref()
2551 .map(|p| p.page_sizes.app_installer)
2552 .unwrap_or(15)
2553 }
2554
2555 pub async fn list_available_backups(&self) -> Result<Vec<BackupInfo>> {
2559 let backup_dir = dirs::home_dir()
2560 .unwrap_or_else(|| PathBuf::from("."))
2561 .join(super::constants::CONFIG_DIR_PATH)
2562 .join("backups");
2563
2564 if !backup_dir.exists() {
2565 return Ok(Vec::new());
2566 }
2567
2568 let mut backups = Vec::new();
2569 let mut entries = tokio::fs::read_dir(&backup_dir).await?;
2570
2571 while let Some(entry) = entries.next_entry().await? {
2572 let path = entry.path();
2573 if let Some(extension) = path.extension() {
2574 if extension == "tgz" {
2575 let metadata = entry.metadata().await?;
2576 let file_name = path
2577 .file_name()
2578 .unwrap_or_default()
2579 .to_string_lossy()
2580 .to_string();
2581
2582 let contents = self.analyze_backup(&path).await.ok();
2584
2585 backups.push(BackupInfo {
2586 file_name: file_name.clone(),
2587 path: path.clone(),
2588 size: metadata.len(),
2589 created: metadata.created().unwrap_or(std::time::UNIX_EPOCH),
2590 display_name: self.format_backup_display_name(&file_name),
2591 contents,
2592 });
2593 }
2594 }
2595 }
2596
2597 backups.sort_by(|a, b| b.created.cmp(&a.created));
2599 Ok(backups)
2600 }
2601
2602 fn format_backup_display_name(&self, file_name: &str) -> String {
2604 let name_without_ext = file_name.strip_suffix(".tgz").unwrap_or(file_name);
2606
2607 if let Some(timestamp_part) = name_without_ext.strip_prefix("vibe-backup-") {
2608 if let Ok(parsed) =
2609 chrono::NaiveDateTime::parse_from_str(timestamp_part, "%Y%m%d-%H%M%S")
2610 {
2611 return format!(
2612 "{} (created {})",
2613 name_without_ext,
2614 parsed.format("%Y-%m-%d %H:%M:%S")
2615 );
2616 }
2617 }
2618
2619 name_without_ext.to_string()
2620 }
2621
2622 fn format_file_size(bytes: u64) -> String {
2624 const KB: u64 = 1024;
2625 const MB: u64 = KB * 1024;
2626 const GB: u64 = MB * 1024;
2627
2628 if bytes >= GB {
2629 format!("{:.1} GB", bytes as f64 / GB as f64)
2630 } else if bytes >= MB {
2631 format!("{:.1} MB", bytes as f64 / MB as f64)
2632 } else if bytes >= KB {
2633 format!("{:.1} kB", bytes as f64 / KB as f64)
2634 } else {
2635 format!("{bytes} B")
2636 }
2637 }
2638
2639 pub async fn restore_from_backup(
2641 &mut self,
2642 backup_path: Option<PathBuf>,
2643 force: bool,
2644 ) -> Result<()> {
2645 let backup_file = if let Some(path) = backup_path {
2646 path
2647 } else {
2648 self.select_backup_interactively().await?
2650 };
2651
2652 if !backup_file.exists() {
2653 anyhow::bail!("Backup file does not exist: {}", backup_file.display());
2654 }
2655
2656 let backup_contents = self.analyze_backup(&backup_file).await?;
2658
2659 if !force {
2660 self.confirm_restore(&backup_file, &backup_contents).await?;
2661 }
2662
2663 println!("{} Starting restore process...", style("đ").blue());
2664
2665 println!(
2667 "{} Clearing existing configuration...",
2668 style("đī¸").yellow()
2669 );
2670 self.factory_reset_with_options(true, true).await?;
2671
2672 self.extract_backup(&backup_file).await?;
2674
2675 println!("{} Rebuilding cache databases...", style("đ").blue());
2677 self.reinitialize_caches().await?;
2678
2679 self.config = WorkspaceConfig::load_from_file(&self.config_path).await?;
2681
2682 println!(
2683 "{} Restore completed successfully!",
2684 style("â
").green().bold()
2685 );
2686 println!(
2687 "{} Run 'vibe menu' to continue using Vibe Workspace",
2688 style("đĄ").blue()
2689 );
2690
2691 Ok(())
2692 }
2693
2694 async fn select_backup_interactively(&self) -> Result<PathBuf> {
2696 let backups = self.list_available_backups().await?;
2697
2698 if backups.is_empty() {
2699 anyhow::bail!("No backup files found in ~/.toolprint/vibe-workspace/backups/");
2700 }
2701
2702 println!("\n{} Available backups:", style("đĻ").blue());
2703
2704 let backup_options: Vec<String> = backups
2705 .iter()
2706 .map(|backup| {
2707 let size_str = Self::format_file_size(backup.size);
2708 let mut details = vec![size_str];
2709
2710 if let Some(contents) = &backup.contents {
2711 let mut content_parts = Vec::new();
2712 if contents.has_config {
2713 content_parts.push("config".to_string());
2714 }
2715 if contents.has_state {
2716 content_parts.push("state".to_string());
2717 }
2718 if contents.has_templates {
2719 content_parts.push("templates".to_string());
2720 }
2721 if !contents.app_configs.is_empty() {
2722 content_parts.push(format!("{} apps", contents.app_configs.len()));
2723 }
2724
2725 if !content_parts.is_empty() {
2726 details.push(format!("{} files", contents.total_files));
2727 details.push(content_parts.join("+"));
2728 }
2729 }
2730
2731 format!("{} ({})", backup.display_name, details.join(", "))
2732 })
2733 .collect();
2734
2735 use inquire::Select;
2736 let selection = Select::new("Select backup to restore:", backup_options)
2737 .with_help_message("Use arrow keys to navigate, Enter to select")
2738 .with_page_size(10)
2739 .prompt()?;
2740
2741 let selected_backup = backups
2743 .iter()
2744 .find(|backup| {
2745 let size_str = Self::format_file_size(backup.size);
2746 let mut details = vec![size_str];
2747
2748 if let Some(contents) = &backup.contents {
2749 let mut content_parts = Vec::new();
2750 if contents.has_config {
2751 content_parts.push("config".to_string());
2752 }
2753 if contents.has_state {
2754 content_parts.push("state".to_string());
2755 }
2756 if contents.has_templates {
2757 content_parts.push("templates".to_string());
2758 }
2759 if !contents.app_configs.is_empty() {
2760 content_parts.push(format!("{} apps", contents.app_configs.len()));
2761 }
2762
2763 if !content_parts.is_empty() {
2764 details.push(format!("{} files", contents.total_files));
2765 details.push(content_parts.join("+"));
2766 }
2767 }
2768
2769 let display = format!("{} ({})", backup.display_name, details.join(", "));
2770 display == selection
2771 })
2772 .context("Selected backup not found")?;
2773
2774 Ok(selected_backup.path.clone())
2775 }
2776
2777 async fn analyze_backup(&self, backup_path: &Path) -> Result<BackupContents> {
2779 use std::process::Command;
2780
2781 let output = Command::new("tar")
2783 .args(["-tzf"])
2784 .arg(backup_path)
2785 .output()
2786 .context("Failed to analyze backup archive")?;
2787
2788 if !output.status.success() {
2789 anyhow::bail!("Failed to read backup archive: Invalid or corrupted file");
2790 }
2791
2792 let contents_list = String::from_utf8_lossy(&output.stdout);
2793 let files: Vec<String> = contents_list.lines().map(|s| s.to_string()).collect();
2794
2795 let mut contents = BackupContents {
2796 has_config: false,
2797 has_state: false,
2798 has_templates: false,
2799 app_configs: Vec::new(),
2800 total_files: files.len(),
2801 };
2802
2803 for file in &files {
2804 let clean_file = file.strip_prefix("./").unwrap_or(file);
2806
2807 if clean_file == "config.yaml" {
2808 contents.has_config = true;
2809 } else if clean_file == "state.json" {
2810 contents.has_state = true;
2811 } else if clean_file.starts_with("templates/") {
2812 contents.has_templates = true;
2813 } else if clean_file.starts_with("app-configs/") {
2814 let parts: Vec<&str> = clean_file.split('/').collect();
2815 if parts.len() >= 2 && !contents.app_configs.contains(&parts[1].to_string()) {
2816 contents.app_configs.push(parts[1].to_string());
2817 }
2818 }
2819 }
2820
2821 Ok(contents)
2822 }
2823
2824 async fn confirm_restore(&self, backup_path: &Path, contents: &BackupContents) -> Result<()> {
2826 use inquire::Confirm;
2827
2828 println!(
2829 "\n{} {}",
2830 style("â ī¸ RESTORE CONFIRMATION").yellow().bold(),
2831 style("This will replace ALL current configuration!").yellow()
2832 );
2833 println!();
2834
2835 let backup_size = if let Ok(metadata) = std::fs::metadata(backup_path) {
2837 Self::format_file_size(metadata.len())
2838 } else {
2839 "unknown".to_string()
2840 };
2841
2842 println!(
2843 "{} Backup file: {} ({})",
2844 style("đĻ").blue(),
2845 backup_path.display(),
2846 backup_size
2847 );
2848 println!("{} Backup contains:", style("đ").blue());
2849
2850 if contents.has_config {
2851 println!(" {} Main configuration (config.yaml)", style("â").green());
2852 }
2853 if contents.has_state {
2854 println!(
2855 " {} User state and preferences (state.json)",
2856 style("â").green()
2857 );
2858 }
2859 if contents.has_templates {
2860 println!(" {} Template files", style("â").green());
2861 }
2862 if !contents.app_configs.is_empty() {
2863 println!(
2864 " {} App configurations: {}",
2865 style("â").green(),
2866 contents.app_configs.join(", ")
2867 );
2868 }
2869
2870 if !contents.has_config {
2872 println!(" {} Main configuration (missing)", style("â ī¸").yellow());
2873 }
2874 if !contents.has_state {
2875 println!(
2876 " {} User state (missing - will use defaults)",
2877 style("âšī¸").blue()
2878 );
2879 }
2880
2881 println!(
2882 " {} Total files: {}",
2883 style("đ").blue(),
2884 contents.total_files
2885 );
2886 println!();
2887
2888 println!("{} This will:", style("â ī¸").yellow());
2889 println!(" âĸ Delete all current configuration");
2890 println!(" âĸ Delete all app-generated files");
2891 println!(" âĸ Restore configuration from backup");
2892 println!(" âĸ Rebuild cache databases");
2893 println!();
2894
2895 let confirm = Confirm::new("Are you sure you want to proceed with the restore?")
2896 .with_default(false)
2897 .prompt()?;
2898
2899 if !confirm {
2900 anyhow::bail!("Restore cancelled by user");
2901 }
2902
2903 Ok(())
2904 }
2905
2906 async fn extract_backup(&self, backup_path: &Path) -> Result<()> {
2908 use std::process::Command;
2909
2910 let temp_dir = tempfile::tempdir().context("Failed to create temporary directory")?;
2912 let temp_path = temp_dir.path();
2913
2914 println!("{} Extracting backup archive...", style("đĻ").blue());
2916 let output = Command::new("tar")
2917 .args(["-xzf"])
2918 .arg(backup_path)
2919 .args(["-C"])
2920 .arg(temp_path)
2921 .output()
2922 .context("Failed to extract backup archive")?;
2923
2924 if !output.status.success() {
2925 let error_msg = String::from_utf8_lossy(&output.stderr);
2926 anyhow::bail!("Failed to extract backup: {}", error_msg);
2927 }
2928
2929 let vibe_dir = super::constants::get_config_dir();
2931 tokio::fs::create_dir_all(&vibe_dir).await?;
2932
2933 let config_src = temp_path.join("config.yaml");
2935 if config_src.exists() {
2936 tokio::fs::copy(&config_src, &self.config_path).await?;
2937 println!("{} Restored main configuration", style("â").green());
2938 }
2939
2940 let state_src = temp_path.join("state.json");
2942 let state_dest = vibe_dir.join("state.json");
2943 if state_src.exists() {
2944 tokio::fs::copy(&state_src, &state_dest).await?;
2945 println!("{} Restored user state", style("â").green());
2946 }
2947
2948 let templates_src = temp_path.join("templates");
2950 let templates_dest = vibe_dir.join("templates");
2951 if templates_src.exists() {
2952 if templates_dest.exists() {
2953 tokio::fs::remove_dir_all(&templates_dest).await?;
2954 }
2955 copy_dir_recursive(&templates_src, &templates_dest)?;
2956 println!("{} Restored templates", style("â").green());
2957 }
2958
2959 let app_configs_src = temp_path.join("app-configs");
2961 if app_configs_src.exists() {
2962 self.restore_app_configs(&app_configs_src).await?;
2963 }
2964
2965 Ok(())
2966 }
2967
2968 async fn restore_app_configs(&self, app_configs_dir: &Path) -> Result<()> {
2970 let temp_config = WorkspaceConfig::load_from_file(&self.config_path).await?;
2972
2973 for app_type in ["warp", "iterm2", "wezterm", "vscode", "cursor", "windsurf"] {
2975 let app_dir = app_configs_dir.join(app_type);
2976 if !app_dir.exists() {
2977 continue;
2978 }
2979
2980 let dest_dir = match app_type {
2981 "warp" => temp_config.apps.warp.as_ref().map(|w| &w.config_dir),
2982 "iterm2" => temp_config.apps.iterm2.as_ref().map(|i| &i.config_dir),
2983 "wezterm" => temp_config.apps.wezterm.as_ref().map(|w| &w.config_dir),
2984 "vscode" => temp_config.apps.vscode.as_ref().map(|v| &v.workspace_dir),
2985 _ => None,
2986 };
2987
2988 if let Some(dest) = dest_dir {
2989 tokio::fs::create_dir_all(dest).await?;
2990 copy_dir_recursive(&app_dir, dest)?;
2991 println!(
2992 "{} Restored {} configurations",
2993 style("â").green(),
2994 app_type
2995 );
2996 }
2997 }
2998
2999 Ok(())
3000 }
3001
3002 async fn reinitialize_caches(&mut self) -> Result<()> {
3004 let vibe_dir = super::constants::get_config_dir();
3005 let cache_dir = vibe_dir.join("cache");
3006
3007 if cache_dir.exists() {
3009 tokio::fs::remove_dir_all(&cache_dir).await?;
3010 }
3011
3012 tokio::fs::create_dir_all(&cache_dir).await?;
3014 self.repo_cache = Some(Self::init_repository_cache(&cache_dir).await?);
3015 self.git_cache = Some(Self::init_git_status_cache(&cache_dir).await?);
3016
3017 let repositories = self.config.repositories.clone();
3019 let workspace_root = self.config.workspace.root.clone();
3020 if let Ok(cache) = self.get_repository_cache().await {
3021 cache
3022 .refresh_from_config(&repositories, &workspace_root)
3023 .await?;
3024 }
3025
3026 println!("{} Cache databases rebuilt", style("â").green());
3027 Ok(())
3028 }
3029}
3030
3031fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
3033 use std::fs;
3034
3035 if src.is_dir() {
3036 fs::create_dir_all(dst)?;
3037
3038 for entry in fs::read_dir(src)? {
3039 let entry = entry?;
3040 let src_path = entry.path();
3041 let dst_path = dst.join(entry.file_name());
3042
3043 if src_path.is_dir() {
3044 copy_dir_recursive(&src_path, &dst_path)?;
3045 } else {
3046 fs::copy(&src_path, &dst_path)?;
3047 }
3048 }
3049 } else {
3050 fs::copy(src, dst)?;
3051 }
3052
3053 Ok(())
3054}