1use std::{
2 path::{Path, PathBuf},
3 process::Command,
4};
5
6use tempfile::TempDir;
7use url::Url;
8
9use crate::error::{Result, TuffError};
10use crate::manifest::CapabilityType;
11
12pub fn is_git_url(s: &str) -> bool {
13 s.starts_with("http://")
14 || s.starts_with("https://")
15 || s.starts_with("git@")
16 || s.starts_with("file://")
17}
18
19fn clean_git_url(raw: &str) -> (String, Option<String>) {
20 let parsed = match Url::parse(raw) {
21 Ok(u) => u,
22 Err(_) => return (raw.to_string(), None),
23 };
24
25 let host = parsed.host_str().unwrap_or("");
26 let path = parsed.path();
27 let segments: Vec<&str> = path.trim_start_matches('/').split('/').collect();
28
29 if (host == "github.com" || host.ends_with(".github.com"))
30 && segments.len() >= 4
31 && (segments[2] == "tree" || segments[2] == "blob")
32 {
33 let clean = format!("https://{}/{}/{}", host, segments[0], segments[1]);
34 return (clean, Some(segments[3].to_string()));
35 }
36
37 if (host == "github.com" || host.ends_with(".github.com")) && segments.len() > 2 {
38 return (
39 format!("https://{}/{}/{}", host, segments[0], segments[1]),
40 None,
41 );
42 }
43
44 if (host == "gitlab.com" || host.ends_with(".gitlab.com"))
45 && segments.len() >= 5
46 && segments[2] == "-"
47 && (segments[3] == "tree" || segments[3] == "blob")
48 {
49 let clean = format!("https://{}/{}/{}", host, segments[0], segments[1]);
50 return (clean, Some(segments[4].to_string()));
51 }
52
53 if (host == "gitlab.com" || host.ends_with(".gitlab.com")) && segments.len() > 2 {
54 return (
55 format!("https://{}/{}/{}", host, segments[0], segments[1]),
56 None,
57 );
58 }
59
60 (raw.to_string(), None)
61}
62
63pub fn source_subdirectory(raw: &str) -> Option<String> {
68 let parsed = Url::parse(raw).ok()?;
69 let host = parsed.host_str()?;
70 let segments: Vec<&str> = parsed.path().trim_start_matches('/').split('/').collect();
71
72 if host == "github.com" || host.ends_with(".github.com") {
73 if segments.len() >= 5 && (segments[2] == "tree" || segments[2] == "blob") {
74 return Some(segments[4..].join("/"));
75 }
76 if segments.len() > 2 && segments[2] != "tree" && segments[2] != "blob" {
77 return Some(segments[2..].join("/"));
78 }
79 }
80
81 if host == "gitlab.com" || host.ends_with(".gitlab.com") {
82 if segments.len() >= 6
83 && segments[2] == "-"
84 && (segments[3] == "tree" || segments[3] == "blob")
85 {
86 return Some(segments[5..].join("/"));
87 }
88 if segments.len() > 2 && segments[2] != "-" {
89 return Some(segments[2..].join("/"));
90 }
91 }
92
93 None
94}
95
96pub fn clone_to_temp(
97 raw_url: &str,
98 resolved_ref: Option<&str>,
99) -> Result<(TempDir, PathBuf, String)> {
100 let (clean_url, branch) = clean_git_url(raw_url);
101 let temp = TempDir::new()?;
102 let checkout = temp.path().join("source");
103 let mut clone = Command::new("git");
104 clone.args(["clone", "--quiet"]);
105 if resolved_ref.is_none() {
106 clone.args(["--depth", "1"]);
107 if let Some(branch) = branch.as_deref() {
108 clone.args(["--branch", branch]);
109 }
110 }
111 clone.arg(&clean_url).arg(&checkout);
112 run_git(
113 &mut clone,
114 &format!("git clone failed for {clean_url}; is the repo accessible?"),
115 )?;
116 if let Some(reference) = resolved_ref {
117 run_git(
118 Command::new("git")
119 .args(["checkout", "--quiet", "--detach", reference])
120 .current_dir(&checkout),
121 &format!("could not check out recorded ref {reference}"),
122 )?;
123 }
124 Ok((temp, checkout, clean_url))
125}
126
127pub fn clone_tag_to_temp(raw_url: &str, tag: &str) -> Result<(TempDir, PathBuf, String)> {
130 let (clean_url, _) = clean_git_url(raw_url);
131 let temp = TempDir::new()?;
132 let checkout = temp.path().join("source");
133 run_git(
134 Command::new("git")
135 .args(["clone", "--quiet", "--depth", "1", "--branch", tag])
136 .arg(&clean_url)
137 .arg(&checkout),
138 &format!("git clone failed for {clean_url} at tag {tag}; is the repo accessible?"),
139 )?;
140 Ok((temp, checkout, clean_url))
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct RemoteTag {
146 pub name: String,
147 pub commit: String,
149}
150
151pub fn list_remote_tags(raw_url: &str) -> Result<Vec<RemoteTag>> {
154 let (clean_url, _) = clean_git_url(raw_url);
155 let listing = ls_remote(&clean_url, &["--tags"], &[])?;
156 Ok(parse_ls_remote_tags(&listing))
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct RemoteRefs {
163 pub head: Option<String>,
168 pub tags: Vec<RemoteTag>,
169}
170
171pub fn list_remote_refs(raw_url: &str) -> Result<RemoteRefs> {
175 let (clean_url, branch) = clean_git_url(raw_url);
176 let head_ref = match branch {
177 Some(branch) => format!("refs/heads/{branch}"),
178 None => "HEAD".to_string(),
179 };
180 let listing = ls_remote(&clean_url, &[], &[&head_ref, "refs/tags/*"])?;
181 let head = listing.lines().find_map(|line| {
182 let (sha, reference) = line.split_once('\t')?;
183 (reference == head_ref).then(|| sha.to_string())
184 });
185 Ok(RemoteRefs {
186 head,
187 tags: parse_ls_remote_tags(&listing),
188 })
189}
190
191fn ls_remote(clean_url: &str, flags: &[&str], patterns: &[&str]) -> Result<String> {
193 let output = Command::new("git")
194 .arg("ls-remote")
195 .args(flags)
196 .arg(clean_url)
197 .args(patterns)
198 .output()?;
199 if !output.status.success() {
200 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
201 let context = format!("git ls-remote failed for {clean_url}; is the repo accessible?");
202 return Err(TuffError::source_failed(if stderr.is_empty() {
203 context
204 } else {
205 format!("{context}: {stderr}")
206 }));
207 }
208 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
209}
210
211fn parse_ls_remote_tags(output: &str) -> Vec<RemoteTag> {
215 let mut tags: Vec<RemoteTag> = Vec::new();
216 for line in output.lines() {
217 let Some((sha, reference)) = line.split_once('\t') else {
218 continue;
219 };
220 let Some(name) = reference.strip_prefix("refs/tags/") else {
221 continue;
222 };
223 match name.strip_suffix("^{}") {
224 Some(peeled) => {
225 if let Some(tag) = tags.iter_mut().find(|tag| tag.name == peeled) {
226 tag.commit = sha.to_string();
227 }
228 }
229 None => tags.push(RemoteTag {
230 name: name.to_string(),
231 commit: sha.to_string(),
232 }),
233 }
234 }
235 tags
236}
237
238fn run_git(cmd: &mut Command, context: &str) -> Result<()> {
239 let output = cmd.output()?;
240 if output.status.success() {
241 return Ok(());
242 }
243
244 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
245 if stderr.is_empty() {
246 Err(TuffError::source_failed(context.to_string()))
247 } else {
248 Err(TuffError::source_failed(format!("{context}: {stderr}")))
249 }
250}
251
252pub fn resolve_ref(repo: &Path) -> Result<String> {
253 let output = Command::new("git")
254 .args(["rev-parse", "HEAD"])
255 .current_dir(repo)
256 .output()?;
257
258 if !output.status.success() {
259 return Err(TuffError::source_failed("failed to resolve git ref"));
260 }
261
262 let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
263 if sha.is_empty() {
264 return Err(TuffError::source_failed("empty git ref"));
265 }
266 Ok(sha)
267}
268
269pub fn discover_capability(
270 repo: &Path,
271 name: &str,
272 capability_type: CapabilityType,
273) -> Result<PathBuf> {
274 let dir_plural = capability_type.plural_dir(); let dir_singular = capability_type.as_str(); let mut matches = Vec::new();
278
279 let direct = repo.join(name);
282 if direct.is_dir() {
283 matches.push(direct);
284 }
285
286 let p1 = repo.join(dir_plural).join(name);
288 if p1.is_dir() {
289 matches.push(p1);
290 }
291
292 let p2 = repo.join(dir_singular).join(name);
294 if p2.is_dir() {
295 matches.push(p2);
296 }
297
298 let p3 = repo.join(name);
300 if p3.is_dir() {
301 matches.push(p3);
302 }
303
304 let plural_dir = repo.join(dir_plural);
306 if plural_dir.is_dir() {
307 for entry in std::fs::read_dir(&plural_dir)? {
308 let entry = entry?;
309 if entry.file_type()?.is_dir() {
310 let candidate = entry.path().join(name);
311 if candidate.is_dir() {
312 matches.push(candidate);
313 }
314 }
315 }
316 }
317
318 matches.sort();
319 matches.dedup();
320 match matches.len() {
321 0 => {
322 let nearby = list_nearby_capabilities(repo, capability_type)?;
323 let hint = if nearby.is_empty() {
324 String::new()
325 } else {
326 format!("\nAvailable {dir_plural}: {}", nearby.join(", "))
327 };
328 Err(TuffError::not_found(format!(
329 "{} '{}' not found in repository{hint}",
330 capability_type, name
331 )))
332 }
333 1 => Ok(matches[0].clone()),
334 _ => {
335 let paths: Vec<_> = matches
336 .iter()
337 .map(|p| p.strip_prefix(repo).unwrap_or(p).display().to_string())
338 .collect();
339 Err(TuffError::usage(format!(
340 "ambiguous capability name '{}' matches multiple paths: {}",
341 name,
342 paths.join(", ")
343 )))
344 }
345 }
346}
347
348fn list_nearby_capabilities(repo: &Path, capability_type: CapabilityType) -> Result<Vec<String>> {
349 let dir_plural = capability_type.plural_dir();
350 let capabilities_dir = repo.join(dir_plural);
351 if !capabilities_dir.is_dir() {
352 return Ok(Vec::new());
353 }
354
355 let mut names = Vec::new();
356 for entry in std::fs::read_dir(&capabilities_dir)? {
357 let entry = entry?;
358 if entry.file_type()?.is_dir() {
359 let name = entry.file_name().to_string_lossy().to_string();
360 if !name.starts_with('.') {
361 names.push(name);
362 }
363 }
364 }
365 names.sort();
366 Ok(names)
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 #[test]
374 fn ls_remote_tags_peel_annotated_tags_to_their_commit() {
375 let output = "aaaa\trefs/tags/v1.0.0\n\
376 bbbb\trefs/tags/v1.2.0\n\
377 cccc\trefs/tags/v1.2.0^{}\n\
378 dddd\trefs/heads/main\n";
379 let tags = parse_ls_remote_tags(output);
380 assert_eq!(
381 tags,
382 vec![
383 RemoteTag {
384 name: "v1.0.0".into(),
385 commit: "aaaa".into()
386 },
387 RemoteTag {
388 name: "v1.2.0".into(),
389 commit: "cccc".into()
390 },
391 ]
392 );
393 }
394
395 #[test]
396 fn detect_github_clean_url() {
397 assert!(is_git_url("https://github.com/owner/repo"));
398 assert!(is_git_url("http://github.com/owner/repo"));
399 }
400
401 #[test]
402 fn detect_github_tree_url() {
403 assert!(is_git_url("https://github.com/owner/repo/tree/main/skills"));
404 }
405
406 #[test]
407 fn detect_ssh_url() {
408 assert!(is_git_url("git@github.com:owner/repo.git"));
409 }
410
411 #[test]
412 fn detect_file_url() {
413 assert!(is_git_url("file:///path/to/repo"));
414 }
415
416 #[test]
417 fn reject_local_path() {
418 assert!(!is_git_url("./my-skill"));
419 assert!(!is_git_url("/absolute/path"));
420 }
421
422 #[test]
423 fn clean_github_tree_extracts_repo_and_branch() {
424 let (url, branch) = clean_git_url("https://github.com/owner/repo/tree/main/skills");
425 assert_eq!(url, "https://github.com/owner/repo");
426 assert_eq!(branch, Some("main".to_string()));
427 }
428
429 #[test]
430 fn clean_github_blob_extracts_repo_and_branch() {
431 let (url, branch) = clean_git_url("https://github.com/owner/repo/blob/main/README.md");
432 assert_eq!(url, "https://github.com/owner/repo");
433 assert_eq!(branch, Some("main".to_string()));
434 }
435
436 #[test]
437 fn clean_plain_url_passes_through() {
438 let (url, branch) = clean_git_url("https://github.com/vercel-labs/skills");
439 assert_eq!(url, "https://github.com/vercel-labs/skills");
440 assert_eq!(branch, None);
441 }
442
443 #[test]
444 fn github_folder_url_is_normalized_and_preserves_subdirectory() {
445 let (url, branch) = clean_git_url(
446 "https://github.com/am-will/codex-skills/hooks/aitmpl-codex/automation/change-logger",
447 );
448 assert_eq!(url, "https://github.com/am-will/codex-skills");
449 assert_eq!(branch, None);
450 assert_eq!(
451 source_subdirectory(
452 "https://github.com/am-will/codex-skills/hooks/aitmpl-codex/automation/change-logger"
453 ),
454 Some("hooks/aitmpl-codex/automation/change-logger".to_string())
455 );
456 }
457
458 #[test]
459 fn github_tree_url_preserves_branch_and_subdirectory() {
460 assert_eq!(
461 source_subdirectory(
462 "https://github.com/am-will/codex-skills/tree/main/hooks/aitmpl-codex/automation/change-logger"
463 ),
464 Some("hooks/aitmpl-codex/automation/change-logger".to_string())
465 );
466 }
467
468 #[test]
469 fn clean_gitlab_tree_extracts_repo_and_branch() {
470 let (url, branch) = clean_git_url("https://gitlab.com/owner/repo/-/tree/main/src");
471 assert_eq!(url, "https://gitlab.com/owner/repo");
472 assert_eq!(branch, Some("main".to_string()));
473 }
474
475 #[test]
476 fn clean_file_url_passes_through() {
477 let (url, branch) = clean_git_url("file:///path/to/repo");
478 assert_eq!(url, "file:///path/to/repo");
479 assert_eq!(branch, None);
480 }
481}