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