Skip to main content

apo/
source.rs

1//! Resolve local paths and remote Git URIs into an analyzable workspace.
2
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use tempfile::TempDir;
7use tracing::info;
8
9use crate::error::{Error, Result};
10
11/// A resolved repository ready for analysis.
12#[derive(Debug)]
13pub struct Workspace {
14    /// Local checkout path.
15    pub path: PathBuf,
16    /// User-facing repository identity (URI or local path).
17    pub label: String,
18    /// Original remote URI when the workspace was cloned.
19    pub source_uri: Option<String>,
20    /// Temporary directory holding a remote clone (cleaned up on drop).
21    _temp: Option<TempDir>,
22}
23
24impl Workspace {
25    /// Whether this workspace was cloned from a remote URI.
26    pub fn is_remote(&self) -> bool {
27        self.source_uri.is_some()
28    }
29}
30
31/// Resolve `target` (local path or remote Git URI) into a [`Workspace`].
32pub fn resolve(target: &str, clone_depth: usize) -> Result<Workspace> {
33    let target = target.trim();
34    if target.is_empty() {
35        return Err(Error::Config("empty repository target".into()));
36    }
37
38    if looks_like_remote(target) {
39        let uri = normalize_remote(target);
40        return clone_remote(&uri, clone_depth);
41    }
42
43    let path = PathBuf::from(target);
44    if !path.exists() {
45        // Ambiguous: looks local but missing — if it resembles a host/path, hint.
46        if target.contains('/') && !target.starts_with('.') && !target.starts_with('/') {
47            return Err(Error::Config(format!(
48                "path does not exist: {target} (did you mean a remote URI? try https://{target})"
49            )));
50        }
51        return Err(Error::Io(std::io::Error::new(
52            std::io::ErrorKind::NotFound,
53            format!("path does not exist: {target}"),
54        )));
55    }
56
57    Ok(Workspace {
58        path,
59        label: target.to_string(),
60        source_uri: None,
61        _temp: None,
62    })
63}
64
65/// Detect whether `target` is a remote Git URI rather than a local path.
66pub fn looks_like_remote(target: &str) -> bool {
67    let t = target.trim();
68    if t.is_empty() {
69        return false;
70    }
71
72    let lower = t.to_ascii_lowercase();
73    if lower.starts_with("https://")
74        || lower.starts_with("http://")
75        || lower.starts_with("git://")
76        || lower.starts_with("ssh://")
77        || lower.starts_with("git@")
78        || lower.starts_with("file://")
79    {
80        return true;
81    }
82
83    // host:path SCP-like form (git@ omitted), e.g. github.com:org/repo.git
84    if let Some((host, rest)) = t.split_once(':')
85        && !host.contains('/')
86        && !host.is_empty()
87        && rest.contains('/')
88        && !Path::new(t).exists()
89    {
90        // Exclude Windows drive letters (C:\...)
91        if host.len() == 1 && host.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
92            return false;
93        }
94        return true;
95    }
96
97    false
98}
99
100fn normalize_remote(target: &str) -> String {
101    let t = target.trim();
102    if t.starts_with("git@") || t.contains("://") || looks_like_scp(t) {
103        return t.to_string();
104    }
105    t.to_string()
106}
107
108fn looks_like_scp(t: &str) -> bool {
109    t.split_once(':')
110        .is_some_and(|(host, rest)| !host.contains('/') && rest.contains('/'))
111}
112
113fn clone_remote(uri: &str, depth: usize) -> Result<Workspace> {
114    let depth = depth.max(1);
115    let temp = TempDir::new().map_err(Error::Io)?;
116    let dest = temp.path().join(repo_dirname(uri));
117
118    info!(%uri, depth, dest = %dest.display(), "cloning remote repository");
119
120    let output = Command::new("git")
121        .args([
122            "clone",
123            "--depth",
124            &depth.to_string(),
125            "--quiet",
126            uri,
127            dest.to_str()
128                .ok_or_else(|| Error::Config("clone destination path is not valid UTF-8".into()))?,
129        ])
130        .output()
131        .map_err(|e| {
132            Error::Git(format!(
133                "failed to run git clone (is git installed and on PATH?): {e}"
134            ))
135        })?;
136
137    if !output.status.success() {
138        let stderr = String::from_utf8_lossy(&output.stderr);
139        let stdout = String::from_utf8_lossy(&output.stdout);
140        let detail = [stderr.trim(), stdout.trim()]
141            .into_iter()
142            .find(|s| !s.is_empty())
143            .unwrap_or("git clone failed");
144        return Err(Error::Git(format!("git clone {uri}: {detail}")));
145    }
146
147    if !dest.join(".git").exists() {
148        return Err(Error::Git(format!(
149            "git clone completed but no .git found at {}",
150            dest.display()
151        )));
152    }
153
154    Ok(Workspace {
155        path: dest,
156        label: uri.to_string(),
157        source_uri: Some(uri.to_string()),
158        _temp: Some(temp),
159    })
160}
161
162fn repo_dirname(uri: &str) -> String {
163    let trimmed = uri.trim_end_matches('/').trim_end_matches(".git");
164    let name = trimmed.rsplit(['/', ':']).next().unwrap_or("repo").trim();
165    if name.is_empty() {
166        "repo".into()
167    } else {
168        name.to_string()
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn detects_https_and_ssh() {
178        assert!(looks_like_remote("https://github.com/thanos/ex_arrow"));
179        assert!(looks_like_remote("http://example.com/r.git"));
180        assert!(looks_like_remote("git@github.com:thanos/ex_arrow.git"));
181        assert!(looks_like_remote(
182            "ssh://git@github.com/thanos/ex_arrow.git"
183        ));
184        assert!(looks_like_remote("git://github.com/thanos/ex_arrow.git"));
185        assert!(looks_like_remote("file:///tmp/foo.git"));
186    }
187
188    #[test]
189    fn rejects_local_paths() {
190        assert!(!looks_like_remote("."));
191        assert!(!looks_like_remote("./repo"));
192        assert!(!looks_like_remote("../repo"));
193        assert!(!looks_like_remote("/tmp/repo"));
194        assert!(!looks_like_remote("C:\\Users\\repo"));
195    }
196
197    #[test]
198    fn dirname_from_uri() {
199        assert_eq!(
200            repo_dirname("https://github.com/thanos/ex_arrow"),
201            "ex_arrow"
202        );
203        assert_eq!(
204            repo_dirname("git@github.com:thanos/ex_arrow.git"),
205            "ex_arrow"
206        );
207    }
208}