code_digest/
remote.rs

1//! Remote repository fetching functionality
2
3use crate::utils::error::CodeDigestError;
4use std::path::PathBuf;
5use std::process::Command;
6use tempfile::TempDir;
7
8#[cfg(unix)]
9use std::fs;
10
11/// Check if gh CLI is available
12pub fn gh_available() -> bool {
13    Command::new("gh")
14        .arg("--version")
15        .output()
16        .map(|output| output.status.success())
17        .unwrap_or(false)
18}
19
20/// Check if git is available
21pub fn git_available() -> bool {
22    Command::new("git")
23        .arg("--version")
24        .output()
25        .map(|output| output.status.success())
26        .unwrap_or(false)
27}
28
29/// Parse GitHub URL to extract owner and repo
30pub fn parse_github_url(url: &str) -> Result<(String, String), CodeDigestError> {
31    let url = url.trim_end_matches('/');
32
33    // Handle both https:// and http:// URLs
34    let parts: Vec<&str> = if url.starts_with("https://github.com/") {
35        url.strip_prefix("https://github.com/")
36            .ok_or_else(|| CodeDigestError::InvalidConfiguration("Invalid GitHub URL".to_string()))?
37            .split('/')
38            .collect()
39    } else if url.starts_with("http://github.com/") {
40        url.strip_prefix("http://github.com/")
41            .ok_or_else(|| CodeDigestError::InvalidConfiguration("Invalid GitHub URL".to_string()))?
42            .split('/')
43            .collect()
44    } else {
45        return Err(CodeDigestError::InvalidConfiguration(
46            "URL must start with https://github.com/ or http://github.com/".to_string(),
47        ));
48    };
49
50    if parts.len() < 2 {
51        return Err(CodeDigestError::InvalidConfiguration(
52            "GitHub URL must contain owner and repository name".to_string(),
53        ));
54    }
55
56    Ok((parts[0].to_string(), parts[1].to_string()))
57}
58
59/// Fetch a repository from GitHub
60pub fn fetch_repository(repo_url: &str, verbose: bool) -> Result<TempDir, CodeDigestError> {
61    let (owner, repo) = parse_github_url(repo_url)?;
62    let temp_dir = TempDir::new().map_err(|e| {
63        CodeDigestError::RemoteFetchError(format!("Failed to create temp directory: {e}"))
64    })?;
65
66    // Set secure permissions on temp directory (0700)
67    #[cfg(unix)]
68    {
69        use std::os::unix::fs::PermissionsExt;
70        let metadata = fs::metadata(temp_dir.path()).map_err(|e| {
71            CodeDigestError::RemoteFetchError(format!("Failed to get temp directory metadata: {e}"))
72        })?;
73        let mut perms = metadata.permissions();
74        perms.set_mode(0o700);
75        fs::set_permissions(temp_dir.path(), perms).map_err(|e| {
76            CodeDigestError::RemoteFetchError(format!(
77                "Failed to set temp directory permissions: {e}"
78            ))
79        })?;
80    }
81
82    if verbose {
83        eprintln!("📥 Fetching repository: {owner}/{repo}");
84    }
85
86    // Try gh first, then fall back to git
87    let success = if gh_available() {
88        if verbose {
89            eprintln!("🔧 Using gh CLI for optimal performance");
90        }
91        clone_with_gh(&owner, &repo, temp_dir.path(), verbose)?
92    } else if git_available() {
93        if verbose {
94            eprintln!("🔧 Using git clone (gh CLI not available)");
95        }
96        clone_with_git(repo_url, temp_dir.path(), verbose)?
97    } else {
98        return Err(CodeDigestError::RemoteFetchError(
99            "Neither gh CLI nor git is available. Please install one of them.".to_string(),
100        ));
101    };
102
103    if !success {
104        return Err(CodeDigestError::RemoteFetchError("Failed to clone repository".to_string()));
105    }
106
107    if verbose {
108        eprintln!("✅ Repository fetched successfully");
109    }
110
111    Ok(temp_dir)
112}
113
114/// Clone repository using gh CLI
115fn clone_with_gh(
116    owner: &str,
117    repo: &str,
118    target_dir: &std::path::Path,
119    verbose: bool,
120) -> Result<bool, CodeDigestError> {
121    let repo_spec = format!("{owner}/{repo}");
122    let mut cmd = Command::new("gh");
123    cmd.arg("repo")
124        .arg("clone")
125        .arg(&repo_spec)
126        .arg(target_dir.join(repo))
127        .arg("--")
128        .arg("--depth")
129        .arg("1");
130
131    if verbose {
132        eprintln!("🔄 Running: gh repo clone {repo_spec} --depth 1");
133    }
134
135    let output = cmd
136        .output()
137        .map_err(|e| CodeDigestError::RemoteFetchError(format!("Failed to run gh: {e}")))?;
138
139    Ok(output.status.success())
140}
141
142/// Clone repository using git
143fn clone_with_git(
144    repo_url: &str,
145    target_dir: &std::path::Path,
146    verbose: bool,
147) -> Result<bool, CodeDigestError> {
148    let repo_name = repo_url.split('/').next_back().ok_or_else(|| {
149        CodeDigestError::InvalidConfiguration("Invalid repository URL".to_string())
150    })?;
151
152    let mut cmd = Command::new("git");
153    cmd.arg("clone").arg("--depth").arg("1").arg(repo_url).arg(target_dir.join(repo_name));
154
155    if verbose {
156        eprintln!("🔄 Running: git clone --depth 1 {repo_url}");
157    }
158
159    let output = cmd
160        .output()
161        .map_err(|e| CodeDigestError::RemoteFetchError(format!("Failed to run git: {e}")))?;
162
163    Ok(output.status.success())
164}
165
166/// Get the path to the cloned repository within the temp directory
167pub fn get_repo_path(temp_dir: &TempDir, repo_url: &str) -> Result<PathBuf, CodeDigestError> {
168    let (_, repo) = parse_github_url(repo_url)?;
169    let repo_path = temp_dir.path().join(&repo);
170
171    if !repo_path.exists() {
172        return Err(CodeDigestError::RemoteFetchError(format!(
173            "Repository directory not found after cloning: {}",
174            repo_path.display()
175        )));
176    }
177
178    Ok(repo_path)
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn test_parse_github_url_https() {
187        let (owner, repo) = parse_github_url("https://github.com/rust-lang/rust").unwrap();
188        assert_eq!(owner, "rust-lang");
189        assert_eq!(repo, "rust");
190    }
191
192    #[test]
193    fn test_parse_github_url_http() {
194        let (owner, repo) = parse_github_url("http://github.com/rust-lang/rust").unwrap();
195        assert_eq!(owner, "rust-lang");
196        assert_eq!(repo, "rust");
197    }
198
199    #[test]
200    fn test_parse_github_url_trailing_slash() {
201        let (owner, repo) = parse_github_url("https://github.com/rust-lang/rust/").unwrap();
202        assert_eq!(owner, "rust-lang");
203        assert_eq!(repo, "rust");
204    }
205
206    #[test]
207    fn test_parse_github_url_invalid() {
208        assert!(parse_github_url("https://gitlab.com/rust-lang/rust").is_err());
209        assert!(parse_github_url("not-a-url").is_err());
210        assert!(parse_github_url("https://github.com/").is_err());
211        assert!(parse_github_url("https://github.com/rust-lang").is_err());
212    }
213
214    #[test]
215    fn test_gh_available() {
216        // This test will pass or fail depending on the environment
217        // We just ensure it doesn't panic
218        let _ = gh_available();
219    }
220
221    #[test]
222    fn test_git_available() {
223        // This test will pass or fail depending on the environment
224        // We just ensure it doesn't panic
225        let _ = git_available();
226    }
227
228    #[test]
229    fn test_get_repo_path() {
230        use std::fs;
231
232        let temp_dir = TempDir::new().unwrap();
233        let repo_url = "https://github.com/owner/repo";
234
235        // Create the expected directory
236        fs::create_dir_all(temp_dir.path().join("repo")).unwrap();
237
238        let path = get_repo_path(&temp_dir, repo_url).unwrap();
239        assert_eq!(path, temp_dir.path().join("repo"));
240    }
241
242    #[test]
243    fn test_get_repo_path_not_found() {
244        let temp_dir = TempDir::new().unwrap();
245        let repo_url = "https://github.com/owner/repo";
246
247        // Don't create the directory
248        let result = get_repo_path(&temp_dir, repo_url);
249        assert!(result.is_err());
250    }
251}