Skip to main content

vibe_workspace/workspace/
install.rs

1use anyhow::{Context, Result};
2use colored::*;
3use std::path::{Path, PathBuf};
4use tokio::process::Command;
5
6use crate::git::{GitConfig, GitError};
7use crate::utils::fs::expand_tilde;
8use crate::utils::git::is_git_available;
9use crate::workspace::config::Repository as ConfigRepository;
10
11pub struct RepositoryInstaller {
12    workspace_root: PathBuf,
13    git_config: GitConfig,
14}
15
16#[derive(Debug, Clone)]
17pub struct InstalledRepository {
18    pub repository: ConfigRepository,
19    pub path: PathBuf,
20    pub post_install_actions: Vec<PostInstallAction>,
21}
22
23#[derive(Debug, Clone)]
24pub enum PostInstallAction {
25    RunNpmInstall,
26    RunCargoCheck,
27    OpenInEditor(String),
28}
29
30impl RepositoryInstaller {
31    pub fn new(workspace_root: PathBuf, git_config: GitConfig) -> Self {
32        Self {
33            workspace_root: expand_tilde(&workspace_root),
34            git_config,
35        }
36    }
37
38    pub async fn install_from_url(&self, url: &str) -> Result<InstalledRepository> {
39        self.install_from_url_with_options(url, None, false, false)
40            .await
41    }
42
43    pub async fn install_from_url_with_options(
44        &self,
45        url: &str,
46        custom_path: Option<PathBuf>,
47        open_after_clone: bool,
48        run_install_commands: bool,
49    ) -> Result<InstalledRepository> {
50        if !is_git_available() {
51            anyhow::bail!("Git is not available on the system");
52        }
53
54        let (org, repo_name) = self.parse_git_url(url)?;
55        let target_path = if let Some(path) = custom_path {
56            expand_tilde(&path)
57        } else {
58            self.calculate_install_path(&org, &repo_name)
59        };
60
61        // Check if repository already exists
62        if target_path.exists() {
63            return Err(GitError::RepositoryExists { path: target_path }.into());
64        }
65
66        println!(
67            "{} Cloning {} to {}",
68            "📦".cyan(),
69            url.cyan().bold(),
70            target_path.display().to_string().green()
71        );
72
73        // Create parent directory if needed
74        if let Some(parent) = target_path.parent() {
75            tokio::fs::create_dir_all(parent)
76                .await
77                .context("Failed to create parent directory")?;
78        }
79
80        // Clone repository
81        self.clone_repository(url, &target_path).await?;
82
83        // Create repository config
84        let installed_repo = self.create_repository_config(&org, &repo_name, url, &target_path)?;
85
86        // Determine post-install actions
87        let mut post_install_actions = Vec::new();
88
89        if run_install_commands {
90            // Check for package.json
91            if target_path.join("package.json").exists() {
92                post_install_actions.push(PostInstallAction::RunNpmInstall);
93            }
94            // Check for Cargo.toml
95            if target_path.join("Cargo.toml").exists() {
96                post_install_actions.push(PostInstallAction::RunCargoCheck);
97            }
98        }
99
100        if open_after_clone {
101            post_install_actions.push(PostInstallAction::OpenInEditor("vscode".to_string()));
102        }
103
104        println!("{} Successfully cloned repository", "✅".green());
105
106        Ok(InstalledRepository {
107            repository: installed_repo,
108            path: target_path,
109            post_install_actions,
110        })
111    }
112
113    fn parse_git_url(&self, url: &str) -> Result<(String, String)> {
114        // Handle different URL formats
115        let url = url.trim();
116
117        // SSH format: git@github.com:org/repo.git
118        if url.starts_with("git@") {
119            let parts: Vec<&str> = url.split(':').collect();
120            if parts.len() != 2 {
121                return Err(GitError::InvalidUrl {
122                    url: url.to_string(),
123                }
124                .into());
125            }
126
127            let path_parts: Vec<&str> = parts[1].trim_end_matches(".git").split('/').collect();
128
129            if path_parts.len() != 2 {
130                return Err(GitError::InvalidUrl {
131                    url: url.to_string(),
132                }
133                .into());
134            }
135
136            return Ok((path_parts[0].to_string(), path_parts[1].to_string()));
137        }
138
139        // HTTPS format: https://github.com/org/repo or https://github.com/org/repo.git
140        if url.starts_with("https://") || url.starts_with("http://") {
141            let parsed_url = url::Url::parse(url).map_err(|_| GitError::InvalidUrl {
142                url: url.to_string(),
143            })?;
144
145            let path = parsed_url
146                .path()
147                .trim_start_matches('/')
148                .trim_end_matches(".git");
149            let path_parts: Vec<&str> = path.split('/').collect();
150
151            if path_parts.len() < 2 {
152                return Err(GitError::InvalidUrl {
153                    url: url.to_string(),
154                }
155                .into());
156            }
157
158            // Handle potential subdirectories (e.g., gitlab.com/group/subgroup/repo)
159            // For now, we'll use the last two components
160            let org = path_parts[path_parts.len() - 2].to_string();
161            let repo = path_parts[path_parts.len() - 1].to_string();
162
163            return Ok((org, repo));
164        }
165
166        // Shorthand format: org/repo
167        let parts: Vec<&str> = url.split('/').collect();
168        if parts.len() == 2 {
169            return Ok((parts[0].to_string(), parts[1].to_string()));
170        }
171
172        Err(GitError::InvalidUrl {
173            url: url.to_string(),
174        }
175        .into())
176    }
177
178    fn calculate_install_path(&self, org: &str, repo: &str) -> PathBuf {
179        if self.git_config.standardize_paths {
180            self.workspace_root.join(org).join(repo)
181        } else {
182            self.workspace_root.join(repo)
183        }
184    }
185
186    async fn clone_repository(&self, url: &str, target_path: &Path) -> Result<()> {
187        let output = Command::new("git")
188            .args(["clone", url, target_path.to_str().unwrap()])
189            .output()
190            .await
191            .context("Failed to execute git clone")?;
192
193        if !output.status.success() {
194            let error_msg = String::from_utf8_lossy(&output.stderr);
195            return Err(GitError::CloneFailed {
196                message: error_msg.to_string(),
197            }
198            .into());
199        }
200
201        Ok(())
202    }
203
204    fn create_repository_config(
205        &self,
206        org: &str,
207        repo_name: &str,
208        url: &str,
209        path: &Path,
210    ) -> Result<ConfigRepository> {
211        use std::collections::HashMap;
212
213        Ok(ConfigRepository {
214            name: format!("{org}/{repo_name}"),
215            path: path.to_path_buf(),
216            url: Some(url.to_string()),
217            branch: None, // Will be detected from the actual repository
218            apps: HashMap::new(),
219            worktree_config: None,
220        })
221    }
222
223    pub async fn execute_post_install_actions(
224        &self,
225        actions: &[PostInstallAction],
226        repo_path: &Path,
227    ) -> Result<()> {
228        for action in actions {
229            match action {
230                PostInstallAction::RunNpmInstall => {
231                    println!("{} Running npm install...", "📦".cyan());
232                    let output = Command::new("npm")
233                        .arg("install")
234                        .current_dir(repo_path)
235                        .output()
236                        .await?;
237
238                    if !output.status.success() {
239                        eprintln!("Warning: npm install failed");
240                    }
241                }
242                PostInstallAction::RunCargoCheck => {
243                    println!("{} Running cargo check...", "🦀".cyan());
244                    let output = Command::new("cargo")
245                        .arg("check")
246                        .current_dir(repo_path)
247                        .output()
248                        .await?;
249
250                    if !output.status.success() {
251                        eprintln!("Warning: cargo check failed");
252                    }
253                }
254                PostInstallAction::OpenInEditor(editor) => {
255                    println!("{} Opening in {}...", "📝".cyan(), editor);
256                    // This would integrate with the existing app launching functionality
257                    // For now, we'll just print a message
258                }
259            }
260        }
261        Ok(())
262    }
263}