1use anyhow::{Context, Result};
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct GitSource {
15 pub url: String,
17 pub owner: String,
19 pub repo: String,
21 pub git_ref: GitRef,
23 pub original: String,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
29pub enum GitRef {
30 #[default]
32 DefaultBranch,
33 Branch(String),
35 Tag(String),
37 Commit(String),
39}
40
41impl GitRef {
42 pub fn as_refspec(&self) -> Option<&str> {
44 match self {
45 GitRef::DefaultBranch => None,
46 GitRef::Branch(b) => Some(b),
47 GitRef::Tag(t) => Some(t),
48 GitRef::Commit(c) => Some(c),
49 }
50 }
51
52 pub fn is_pinned(&self) -> bool {
54 matches!(self, GitRef::Tag(_) | GitRef::Commit(_))
55 }
56}
57
58impl std::fmt::Display for GitRef {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 match self {
61 GitRef::DefaultBranch => write!(f, "HEAD"),
62 GitRef::Branch(b) => write!(f, "{}", b),
63 GitRef::Tag(t) => write!(f, "{}", t),
64 GitRef::Commit(c) => write!(f, "{}", &c[..7.min(c.len())]),
65 }
66 }
67}
68
69impl GitSource {
70 pub fn cache_key(&self) -> String {
72 format!("{}/{}", self.owner, self.repo)
73 }
74
75 pub fn display_name(&self) -> String {
77 match &self.git_ref {
78 GitRef::DefaultBranch => format!("{}/{}", self.owner, self.repo),
79 ref_type => format!("{}/{}@{}", self.owner, self.repo, ref_type),
80 }
81 }
82}
83
84pub fn parse_git_url(input: &str) -> Result<GitSource> {
95 let original = input.to_string();
96
97 if let Some(rest) = input.strip_prefix("github:") {
99 return parse_shorthand("github.com", rest, original);
100 }
101 if let Some(rest) = input.strip_prefix("gitlab:") {
102 return parse_shorthand("gitlab.com", rest, original);
103 }
104 if let Some(rest) = input.strip_prefix("bitbucket:") {
105 return parse_shorthand("bitbucket.org", rest, original);
106 }
107
108 if input.starts_with("git@") {
110 return parse_ssh_url(input, original);
111 }
112
113 if input.starts_with("https://") || input.starts_with("http://") {
115 return parse_https_url(input, original);
116 }
117
118 anyhow::bail!(
119 "Unsupported Git URL format: {}\n\
120 Supported formats:\n\
121 - github:user/repo\n\
122 - github:user/repo@v1.0.0\n\
123 - https://github.com/user/repo\n\
124 - git@github.com:user/repo.git",
125 input
126 );
127}
128
129fn parse_shorthand(host: &str, rest: &str, original: String) -> Result<GitSource> {
130 let (path, git_ref) = if let Some(at_pos) = rest.rfind('@') {
132 let ref_str = &rest[at_pos + 1..];
133 let path = &rest[..at_pos];
134 (path, parse_ref(ref_str))
135 } else {
136 (rest, GitRef::DefaultBranch)
137 };
138
139 let parts: Vec<&str> = path.split('/').collect();
140 if parts.len() < 2 {
141 anyhow::bail!(
142 "Invalid shorthand format '{}'. Expected: user/repo or user/repo@version",
143 rest
144 );
145 }
146
147 let owner = parts[0].to_string();
148 let repo = parts[1].trim_end_matches(".git").to_string();
149
150 Ok(GitSource {
151 url: format!("https://{}/{}/{}.git", host, owner, repo),
152 owner,
153 repo,
154 git_ref,
155 original,
156 })
157}
158
159fn parse_ssh_url(input: &str, original: String) -> Result<GitSource> {
160 let without_prefix = input
162 .strip_prefix("git@")
163 .context("Invalid SSH URL format")?;
164
165 let colon_pos = without_prefix
166 .find(':')
167 .context("Invalid SSH URL: missing colon separator")?;
168
169 let host = &without_prefix[..colon_pos];
170 let path = &without_prefix[colon_pos + 1..];
171
172 let parts: Vec<&str> = path.trim_end_matches(".git").split('/').collect();
173 if parts.len() < 2 {
174 anyhow::bail!("Invalid SSH URL: expected user/repo format after host");
175 }
176
177 Ok(GitSource {
178 url: format!("https://{}/{}", host, path),
179 owner: parts[0].to_string(),
180 repo: parts[1].trim_end_matches(".git").to_string(),
181 git_ref: GitRef::DefaultBranch,
182 original,
183 })
184}
185
186fn parse_https_url(input: &str, original: String) -> Result<GitSource> {
187 let url = url::Url::parse(input).context("Invalid URL")?;
188 let host = url.host_str().context("Missing host in URL")?;
189
190 let path_segments: Vec<&str> = url
191 .path_segments()
192 .context("Invalid URL path")?
193 .filter(|s| !s.is_empty())
194 .collect();
195
196 if path_segments.len() < 2 {
197 anyhow::bail!("URL must include owner/repo path: {}", input);
198 }
199
200 let owner = path_segments[0].to_string();
201 let repo = path_segments[1].trim_end_matches(".git").to_string();
202
203 let git_ref = if let Some(fragment) = url.fragment() {
205 parse_ref(fragment)
206 } else {
207 GitRef::DefaultBranch
208 };
209
210 Ok(GitSource {
211 url: format!("https://{}/{}/{}.git", host, owner, repo),
212 owner,
213 repo,
214 git_ref,
215 original,
216 })
217}
218
219fn parse_ref(ref_str: &str) -> GitRef {
220 if ref_str.starts_with('v')
222 && ref_str
223 .chars()
224 .nth(1)
225 .map_or(false, |c| c.is_ascii_digit())
226 {
227 GitRef::Tag(ref_str.to_string())
228 }
229 else if ref_str.len() == 40 && ref_str.chars().all(|c| c.is_ascii_hexdigit()) {
231 GitRef::Commit(ref_str.to_string())
232 }
233 else {
235 GitRef::Branch(ref_str.to_string())
236 }
237}
238
239pub fn is_git_url(input: &str) -> bool {
241 input.starts_with("https://github.com")
242 || input.starts_with("https://gitlab.com")
243 || input.starts_with("https://bitbucket.org")
244 || input.starts_with("github:")
245 || input.starts_with("gitlab:")
246 || input.starts_with("bitbucket:")
247 || input.starts_with("git@")
248 || (input.starts_with("https://") && input.ends_with(".git"))
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn test_github_shorthand() {
257 let source = parse_git_url("github:user/my-skill").unwrap();
258 assert_eq!(source.owner, "user");
259 assert_eq!(source.repo, "my-skill");
260 assert_eq!(source.url, "https://github.com/user/my-skill.git");
261 assert_eq!(source.git_ref, GitRef::DefaultBranch);
262 }
263
264 #[test]
265 fn test_github_shorthand_with_tag() {
266 let source = parse_git_url("github:user/my-skill@v1.0.0").unwrap();
267 assert_eq!(source.repo, "my-skill");
268 assert!(matches!(source.git_ref, GitRef::Tag(ref t) if t == "v1.0.0"));
269 }
270
271 #[test]
272 fn test_github_shorthand_with_branch() {
273 let source = parse_git_url("github:user/repo@main").unwrap();
274 assert!(matches!(source.git_ref, GitRef::Branch(ref b) if b == "main"));
275 }
276
277 #[test]
278 fn test_https_url() {
279 let source = parse_git_url("https://github.com/user/repo").unwrap();
280 assert_eq!(source.owner, "user");
281 assert_eq!(source.repo, "repo");
282 assert_eq!(source.url, "https://github.com/user/repo.git");
283 }
284
285 #[test]
286 fn test_https_url_with_git_suffix() {
287 let source = parse_git_url("https://github.com/user/repo.git").unwrap();
288 assert_eq!(source.repo, "repo");
289 }
290
291 #[test]
292 fn test_ssh_url() {
293 let source = parse_git_url("git@github.com:user/repo.git").unwrap();
294 assert_eq!(source.owner, "user");
295 assert_eq!(source.repo, "repo");
296 }
297
298 #[test]
299 fn test_gitlab_shorthand() {
300 let source = parse_git_url("gitlab:org/project").unwrap();
301 assert_eq!(source.url, "https://gitlab.com/org/project.git");
302 }
303
304 #[test]
305 fn test_is_git_url() {
306 assert!(is_git_url("github:user/repo"));
307 assert!(is_git_url("https://github.com/user/repo"));
308 assert!(is_git_url("git@github.com:user/repo.git"));
309 assert!(!is_git_url("./local/path"));
310 assert!(!is_git_url("/absolute/path"));
311 assert!(!is_git_url("my-skill"));
312 }
313
314 #[test]
315 fn test_cache_key() {
316 let source = parse_git_url("github:user/repo@v1.0.0").unwrap();
317 assert_eq!(source.cache_key(), "user/repo");
318 }
319
320 #[test]
321 fn test_display_name() {
322 let source = parse_git_url("github:user/repo").unwrap();
323 assert_eq!(source.display_name(), "user/repo");
324
325 let source_with_tag = parse_git_url("github:user/repo@v1.0.0").unwrap();
326 assert_eq!(source_with_tag.display_name(), "user/repo@v1.0.0");
327 }
328
329 #[test]
330 fn test_commit_sha() {
331 let sha = "abc123def456789012345678901234567890abcd";
332 let source = parse_git_url(&format!("github:user/repo@{}", sha)).unwrap();
333 assert!(matches!(source.git_ref, GitRef::Commit(ref c) if c == sha));
334 }
335}