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
127fn run_git(cmd: &mut Command, context: &str) -> Result<()> {
128 let output = cmd.output()?;
129 if output.status.success() {
130 return Ok(());
131 }
132
133 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
134 if stderr.is_empty() {
135 Err(TuffError::new(context.to_string()))
136 } else {
137 Err(TuffError::new(format!("{context}: {stderr}")))
138 }
139}
140
141pub fn resolve_ref(repo: &Path) -> Result<String> {
142 let output = Command::new("git")
143 .args(["rev-parse", "HEAD"])
144 .current_dir(repo)
145 .output()?;
146
147 if !output.status.success() {
148 return Err(TuffError::new("failed to resolve git ref"));
149 }
150
151 let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
152 if sha.is_empty() {
153 return Err(TuffError::new("empty git ref"));
154 }
155 Ok(sha)
156}
157
158pub fn discover_capability(
159 repo: &Path,
160 name: &str,
161 capability_type: CapabilityType,
162) -> Result<PathBuf> {
163 let dir_plural = capability_type.plural_dir(); let dir_singular = capability_type.as_str(); let mut matches = Vec::new();
167
168 let direct = repo.join(name);
171 if direct.is_dir() {
172 matches.push(direct);
173 }
174
175 let p1 = repo.join(dir_plural).join(name);
177 if p1.is_dir() {
178 matches.push(p1);
179 }
180
181 let p2 = repo.join(dir_singular).join(name);
183 if p2.is_dir() {
184 matches.push(p2);
185 }
186
187 let p3 = repo.join(name);
189 if p3.is_dir() {
190 matches.push(p3);
191 }
192
193 let plural_dir = repo.join(dir_plural);
195 if plural_dir.is_dir() {
196 for entry in std::fs::read_dir(&plural_dir)? {
197 let entry = entry?;
198 if entry.file_type()?.is_dir() {
199 let candidate = entry.path().join(name);
200 if candidate.is_dir() {
201 matches.push(candidate);
202 }
203 }
204 }
205 }
206
207 matches.sort();
208 matches.dedup();
209 match matches.len() {
210 0 => {
211 let nearby = list_nearby_capabilities(repo, capability_type)?;
212 let hint = if nearby.is_empty() {
213 String::new()
214 } else {
215 format!("\nAvailable {dir_plural}: {}", nearby.join(", "))
216 };
217 Err(TuffError::new(format!(
218 "{} '{}' not found in repository{hint}",
219 capability_type, name
220 )))
221 }
222 1 => Ok(matches[0].clone()),
223 _ => {
224 let paths: Vec<_> = matches
225 .iter()
226 .map(|p| p.strip_prefix(repo).unwrap_or(p).display().to_string())
227 .collect();
228 Err(TuffError::new(format!(
229 "ambiguous capability name '{}' matches multiple paths: {}",
230 name,
231 paths.join(", ")
232 )))
233 }
234 }
235}
236
237fn list_nearby_capabilities(repo: &Path, capability_type: CapabilityType) -> Result<Vec<String>> {
238 let dir_plural = capability_type.plural_dir();
239 let capabilities_dir = repo.join(dir_plural);
240 if !capabilities_dir.is_dir() {
241 return Ok(Vec::new());
242 }
243
244 let mut names = Vec::new();
245 for entry in std::fs::read_dir(&capabilities_dir)? {
246 let entry = entry?;
247 if entry.file_type()?.is_dir() {
248 let name = entry.file_name().to_string_lossy().to_string();
249 if !name.starts_with('.') {
250 names.push(name);
251 }
252 }
253 }
254 names.sort();
255 Ok(names)
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn detect_github_clean_url() {
264 assert!(is_git_url("https://github.com/owner/repo"));
265 assert!(is_git_url("http://github.com/owner/repo"));
266 }
267
268 #[test]
269 fn detect_github_tree_url() {
270 assert!(is_git_url("https://github.com/owner/repo/tree/main/skills"));
271 }
272
273 #[test]
274 fn detect_ssh_url() {
275 assert!(is_git_url("git@github.com:owner/repo.git"));
276 }
277
278 #[test]
279 fn detect_file_url() {
280 assert!(is_git_url("file:///path/to/repo"));
281 }
282
283 #[test]
284 fn reject_local_path() {
285 assert!(!is_git_url("./my-skill"));
286 assert!(!is_git_url("/absolute/path"));
287 }
288
289 #[test]
290 fn clean_github_tree_extracts_repo_and_branch() {
291 let (url, branch) = clean_git_url("https://github.com/owner/repo/tree/main/skills");
292 assert_eq!(url, "https://github.com/owner/repo");
293 assert_eq!(branch, Some("main".to_string()));
294 }
295
296 #[test]
297 fn clean_github_blob_extracts_repo_and_branch() {
298 let (url, branch) = clean_git_url("https://github.com/owner/repo/blob/main/README.md");
299 assert_eq!(url, "https://github.com/owner/repo");
300 assert_eq!(branch, Some("main".to_string()));
301 }
302
303 #[test]
304 fn clean_plain_url_passes_through() {
305 let (url, branch) = clean_git_url("https://github.com/vercel-labs/skills");
306 assert_eq!(url, "https://github.com/vercel-labs/skills");
307 assert_eq!(branch, None);
308 }
309
310 #[test]
311 fn github_folder_url_is_normalized_and_preserves_subdirectory() {
312 let (url, branch) = clean_git_url(
313 "https://github.com/am-will/codex-skills/hooks/aitmpl-codex/automation/change-logger",
314 );
315 assert_eq!(url, "https://github.com/am-will/codex-skills");
316 assert_eq!(branch, None);
317 assert_eq!(
318 source_subdirectory(
319 "https://github.com/am-will/codex-skills/hooks/aitmpl-codex/automation/change-logger"
320 ),
321 Some("hooks/aitmpl-codex/automation/change-logger".to_string())
322 );
323 }
324
325 #[test]
326 fn github_tree_url_preserves_branch_and_subdirectory() {
327 assert_eq!(
328 source_subdirectory(
329 "https://github.com/am-will/codex-skills/tree/main/hooks/aitmpl-codex/automation/change-logger"
330 ),
331 Some("hooks/aitmpl-codex/automation/change-logger".to_string())
332 );
333 }
334
335 #[test]
336 fn clean_gitlab_tree_extracts_repo_and_branch() {
337 let (url, branch) = clean_git_url("https://gitlab.com/owner/repo/-/tree/main/src");
338 assert_eq!(url, "https://gitlab.com/owner/repo");
339 assert_eq!(branch, Some("main".to_string()));
340 }
341
342 #[test]
343 fn clean_file_url_passes_through() {
344 let (url, branch) = clean_git_url("file:///path/to/repo");
345 assert_eq!(url, "file:///path/to/repo");
346 assert_eq!(branch, None);
347 }
348}