1use std::path::{Path, PathBuf};
16use std::process::Command;
17
18use super::store::Store;
19use super::{PkgError, PkgResult};
20
21pub struct Staged {
23 pub dir: PathBuf,
25 pub name: String,
27 pub source: String,
30}
31
32pub fn resolve(spec: &str, store: &Store) -> PkgResult<Staged> {
35 let (base, git_ref) = split_ref(spec);
36
37 if let Some(p) = local_path(base) {
39 let dir = p
40 .canonicalize()
41 .map_err(|e| PkgError::Resolve(format!("path {}: {}", p.display(), e)))?;
42 if !dir.is_dir() {
43 return Err(PkgError::Resolve(format!(
44 "path {} is not a directory",
45 dir.display()
46 )));
47 }
48 let name = basename(&dir);
49 let source = format!("path+file://{}", dir.display());
50 return Ok(Staged { dir, name, source });
51 }
52
53 let (url, label, name) = git_url(base)?;
55 store
56 .ensure_layout()
57 .map_err(|e| PkgError::Resolve(e.to_string()))?;
58 let dir = store.git_dir().join(&name);
59 if dir.exists() {
60 std::fs::remove_dir_all(&dir)
61 .map_err(|e| PkgError::Io(format!("clear {}: {}", dir.display(), e)))?;
62 }
63 git_clone(&url, &dir, git_ref)?;
64 Ok(Staged {
65 dir,
66 name,
67 source: label_with_ref(label, git_ref),
70 })
71}
72
73fn label_with_ref(label: String, git_ref: Option<&str>) -> String {
76 match git_ref {
77 Some(r) => format!("{}@{}", label, r),
78 None => label,
79 }
80}
81
82pub fn source_label(spec: &str) -> Option<String> {
88 let (base, git_ref) = split_ref(spec);
89 if let Some(p) = local_path(base) {
90 let dir = p.canonicalize().ok()?;
92 return Some(format!("path+file://{}", dir.display()));
93 }
94 git_url(base)
95 .ok()
96 .map(|(_url, label, _name)| label_with_ref(label, git_ref))
97}
98
99fn split_ref(spec: &str) -> (&str, Option<&str>) {
102 if let Some(at) = spec.rfind('@') {
105 let after_slash = spec.rfind('/').map(|s| at > s).unwrap_or(true);
106 if after_slash && at + 1 < spec.len() {
107 return (&spec[..at], Some(&spec[at + 1..]));
108 }
109 }
110 (spec, None)
111}
112
113fn local_path(spec: &str) -> Option<PathBuf> {
115 if let Some(rest) = spec.strip_prefix("path:") {
116 return Some(PathBuf::from(rest));
117 }
118 if spec.starts_with('/')
119 || spec.starts_with("./")
120 || spec.starts_with("../")
121 || spec.starts_with('~')
122 {
123 let expanded = if let Some(rest) = spec.strip_prefix("~/") {
124 if let Some(home) = std::env::var_os("HOME") {
125 PathBuf::from(home).join(rest)
126 } else {
127 PathBuf::from(spec)
128 }
129 } else {
130 PathBuf::from(spec)
131 };
132 return Some(expanded);
133 }
134 None
135}
136
137fn git_url(spec: &str) -> PkgResult<(String, String, String)> {
139 if let Some(rest) = spec.strip_prefix("git+") {
140 let name = repo_basename(rest);
141 return Ok((rest.to_string(), format!("git+{}", rest), name));
142 }
143 if let Some(rest) = spec.strip_prefix("github:") {
144 let url = format!("https://github.com/{}", rest.trim_end_matches(".git"));
145 let name = repo_basename(&url);
146 return Ok((
147 url,
148 format!("github:{}", rest.trim_end_matches(".git")),
149 name,
150 ));
151 }
152 if spec.ends_with(".git") || spec.contains("://") {
153 let name = repo_basename(spec);
154 return Ok((spec.to_string(), format!("git+{}", spec), name));
155 }
156 if spec.split('/').count() == 2 && !spec.contains(' ') {
158 let owner_repo = spec.trim_end_matches(".git");
159 let url = format!("https://github.com/{}", owner_repo);
160 let name = repo_basename(&url);
161 return Ok((url, format!("github:{}", owner_repo), name));
162 }
163 Err(PkgError::Resolve(format!(
164 "unrecognized source '{}': expected owner/repo, github:owner/repo, \
165 git+URL, or a local path",
166 spec
167 )))
168}
169
170fn git_clone(url: &str, dir: &Path, git_ref: Option<&str>) -> PkgResult<()> {
172 let mut cmd = Command::new("git");
173 cmd.arg("clone").arg("--depth").arg("1");
174 if let Some(r) = git_ref {
175 cmd.arg("--branch").arg(r);
176 }
177 cmd.arg(url).arg(dir);
178 let out = cmd
179 .output()
180 .map_err(|e| PkgError::Resolve(format!("git clone: {} (is git installed?)", e)))?;
181 if !out.status.success() {
182 if git_ref.is_some() {
185 return git_clone_checkout(url, dir, git_ref.unwrap());
186 }
187 return Err(PkgError::Resolve(format!(
188 "git clone {} failed: {}",
189 url,
190 String::from_utf8_lossy(&out.stderr).trim()
191 )));
192 }
193 Ok(())
194}
195
196fn git_clone_checkout(url: &str, dir: &Path, git_ref: &str) -> PkgResult<()> {
199 if dir.exists() {
200 let _ = std::fs::remove_dir_all(dir);
201 }
202 let out = Command::new("git")
203 .arg("clone")
204 .arg(url)
205 .arg(dir)
206 .output()
207 .map_err(|e| PkgError::Resolve(format!("git clone: {}", e)))?;
208 if !out.status.success() {
209 return Err(PkgError::Resolve(format!(
210 "git clone {} failed: {}",
211 url,
212 String::from_utf8_lossy(&out.stderr).trim()
213 )));
214 }
215 let out = Command::new("git")
216 .current_dir(dir)
217 .arg("checkout")
218 .arg(git_ref)
219 .output()
220 .map_err(|e| PkgError::Resolve(format!("git checkout: {}", e)))?;
221 if !out.status.success() {
222 return Err(PkgError::Resolve(format!(
223 "git checkout {} failed: {}",
224 git_ref,
225 String::from_utf8_lossy(&out.stderr).trim()
226 )));
227 }
228 Ok(())
229}
230
231fn basename(p: &Path) -> String {
233 p.file_name()
234 .map(|s| s.to_string_lossy().into_owned())
235 .unwrap_or_else(|| "plugin".into())
236}
237
238fn repo_basename(url: &str) -> String {
240 url.trim_end_matches('/')
241 .trim_end_matches(".git")
242 .rsplit(['/', ':'])
243 .next()
244 .unwrap_or("plugin")
245 .to_string()
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn split_ref_only_after_last_slash() {
254 assert_eq!(split_ref("o/r@v1"), ("o/r", Some("v1")));
255 assert_eq!(split_ref("o/r"), ("o/r", None));
256 assert_eq!(
258 split_ref("git@github.com:o/r.git"),
259 ("git@github.com:o/r.git", None)
260 );
261 }
262
263 #[test]
264 fn git_url_forms() {
265 let (u, l, n) = git_url("owner/repo").unwrap();
266 assert_eq!(u, "https://github.com/owner/repo");
267 assert_eq!(l, "github:owner/repo");
268 assert_eq!(n, "repo");
269 let (u, _, n) = git_url("github:a/b").unwrap();
270 assert_eq!(u, "https://github.com/a/b");
271 assert_eq!(n, "b");
272 let (u, l, _) = git_url("git+https://x.com/y.git").unwrap();
273 assert_eq!(u, "https://x.com/y.git");
274 assert_eq!(l, "git+https://x.com/y.git");
275 assert!(git_url("not a source").is_err());
276 }
277
278 #[test]
279 fn local_path_forms() {
280 assert!(local_path("path:/tmp/x").is_some());
281 assert!(local_path("/abs").is_some());
282 assert!(local_path("./rel").is_some());
283 assert!(local_path("owner/repo").is_none());
284 }
285}