1use std::path::{Path, PathBuf};
2
3use crate::platform::path_syntax::classify_local_source;
4use crate::types::{SourceSubpath, SourceUrl};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum SourceFormat {
9 LocalPath,
10 GitHubShorthand,
11 GitHubAlias,
12 GitHubUrl,
13 GitLabAlias,
14 GitLabUrl,
15 GenericGit,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ParsedSourceSpec {
21 pub format: SourceFormat,
22 pub raw: String,
23 pub url: Option<SourceUrl>,
24 pub path: Option<PathBuf>,
25 pub subpath: Option<SourceSubpath>,
26 pub version: Option<String>,
27 pub name: String,
28}
29
30#[derive(Debug, thiserror::Error, PartialEq, Eq)]
32pub enum ParseError {
33 #[error(
34 "cannot determine source type for {input:?} — expected a local path, supported git source, or owner/repo shorthand"
35 )]
36 UnrecognizedFormat { input: String },
37
38 #[error("unsupported source form for v1: {input:?} ({reason})")]
39 UnsupportedSource { input: String, reason: String },
40
41 #[error("SSH URL {input:?} is missing the colon-separated path (expected git@host:owner/repo)")]
42 MalformedSshUrl { input: String },
43
44 #[error("cannot derive a name from {input:?}")]
45 CannotDeriveName { input: String },
46
47 #[error("URL {input:?} has no repository path component")]
48 EmptyUrlPath { input: String },
49
50 #[error("invalid subpath {input:?}: {reason}")]
51 InvalidSubpath { input: String, reason: String },
52
53 #[error(
54 "tree URL {input:?} uses a slashy branch name that is ambiguous in the path; use the equivalent #ref form instead"
55 )]
56 SlashyTreeRef { input: String },
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60struct ParsedHttpUrl {
61 scheme: String,
62 host: String,
63 authority: String,
64 path_segments: Vec<String>,
65}
66
67pub fn parse(input: &str) -> Result<ParsedSourceSpec, ParseError> {
69 let trimmed = input.trim();
70 if trimmed.is_empty() {
71 return Err(ParseError::UnrecognizedFormat {
72 input: input.to_string(),
73 });
74 }
75
76 if let Some(path) = classify_local_source(trimmed) {
77 let name = derive_path_name(&path, None)?;
78 return Ok(ParsedSourceSpec {
79 format: SourceFormat::LocalPath,
80 raw: input.to_string(),
81 url: None,
82 path: Some(path),
83 subpath: None,
84 version: None,
85 name,
86 });
87 }
88
89 let (without_fragment, fragment_version) = split_fragment(trimmed);
90 let (base, legacy_version) = if fragment_version.is_none() {
91 split_legacy_version(without_fragment)
92 } else {
93 (without_fragment, None)
94 };
95 let version = fragment_version.or(legacy_version.map(str::to_string));
96
97 if let Some(spec) = parse_github_alias(base, version.clone())? {
98 return Ok(spec.with_raw(input));
99 }
100 if let Some(spec) = parse_github_tree_url(base, version.clone())? {
101 return Ok(spec.with_raw(input));
102 }
103 if let Some(spec) = parse_github_repo_url(base, version.clone())? {
104 return Ok(spec.with_raw(input));
105 }
106 if let Some(spec) = parse_gitlab_alias(base, version.clone())? {
107 return Ok(spec.with_raw(input));
108 }
109 if let Some(spec) = parse_gitlab_tree_url(base, version.clone())? {
110 return Ok(spec.with_raw(input));
111 }
112 if let Some(spec) = parse_gitlab_repo_url(base, version.clone())? {
113 return Ok(spec.with_raw(input));
114 }
115 if let Some(spec) = parse_github_shorthand(base, version.clone())? {
116 return Ok(spec.with_raw(input));
117 }
118
119 reject_unsupported_url(base)?;
120
121 if let Some(spec) = parse_generic_git(base, version)? {
122 return Ok(spec.with_raw(input));
123 }
124
125 Err(ParseError::UnrecognizedFormat {
126 input: input.to_string(),
127 })
128}
129
130impl ParsedSourceSpec {
131 fn with_raw(mut self, raw: &str) -> Self {
132 self.raw = raw.to_string();
133 self
134 }
135}
136
137fn spec_from_git(
138 format: SourceFormat,
139 repo_url: String,
140 repo_name: &str,
141 subpath: Option<SourceSubpath>,
142 version: Option<String>,
143) -> ParsedSourceSpec {
144 let name = derive_git_name(repo_name, subpath.as_ref());
145 ParsedSourceSpec {
146 format,
147 raw: String::new(),
148 url: Some(SourceUrl::from(repo_url)),
149 path: None,
150 subpath,
151 version,
152 name,
153 }
154}
155
156fn parse_github_alias(
157 input: &str,
158 version: Option<String>,
159) -> Result<Option<ParsedSourceSpec>, ParseError> {
160 let payload = match input.strip_prefix("github:") {
161 Some(payload) => payload,
162 None => return Ok(None),
163 };
164
165 let segments = collect_non_empty_segments(payload);
166 if segments.len() < 2 {
167 return Err(ParseError::EmptyUrlPath {
168 input: input.to_string(),
169 });
170 }
171
172 let owner = &segments[0];
173 let repo = strip_git_suffix(&segments[1]);
174 let subpath = normalize_subpath_segments(&segments[2..])?;
175 Ok(Some(spec_from_git(
176 SourceFormat::GitHubAlias,
177 format!("https://github.com/{owner}/{repo}"),
178 repo,
179 subpath,
180 version,
181 )))
182}
183
184fn parse_gitlab_alias(
185 input: &str,
186 version: Option<String>,
187) -> Result<Option<ParsedSourceSpec>, ParseError> {
188 let payload = match input.strip_prefix("gitlab:") {
189 Some(payload) => payload,
190 None => return Ok(None),
191 };
192
193 let segments = collect_non_empty_segments(payload);
194 if segments.len() < 2 {
195 return Err(ParseError::EmptyUrlPath {
196 input: input.to_string(),
197 });
198 }
199
200 let repo = strip_git_suffix(segments.last().expect("segments checked"));
201 Ok(Some(spec_from_git(
202 SourceFormat::GitLabAlias,
203 format!("https://gitlab.com/{}", segments.join("/")),
204 repo,
205 None,
206 version,
207 )))
208}
209
210fn parse_github_tree_url(
211 input: &str,
212 version: Option<String>,
213) -> Result<Option<ParsedSourceSpec>, ParseError> {
214 let url = match parse_http_like_url(input) {
215 Some(url) if url.host == "github.com" => url,
216 _ => return Ok(None),
217 };
218
219 if url.path_segments.len() >= 4 && url.path_segments[2] == "tree" {
220 let owner = &url.path_segments[0];
221 let repo = strip_git_suffix(&url.path_segments[1]);
222 let tree_ref = decode_ref_segment(&url.path_segments[3], input)?;
223 let subpath = normalize_subpath_segments(&url.path_segments[4..])?;
224
225 return Ok(Some(spec_from_git(
226 SourceFormat::GitHubUrl,
227 format!("https://github.com/{owner}/{repo}"),
228 repo,
229 subpath,
230 version.or(Some(tree_ref)),
231 )));
232 }
233
234 Ok(None)
235}
236
237fn parse_github_repo_url(
238 input: &str,
239 version: Option<String>,
240) -> Result<Option<ParsedSourceSpec>, ParseError> {
241 let url = match parse_http_like_url(input) {
242 Some(url) if url.host == "github.com" => url,
243 Some(url) if url.host == "github.com" && url.path_segments.is_empty() => {
244 return Err(ParseError::EmptyUrlPath {
245 input: input.to_string(),
246 });
247 }
248 _ => return Ok(None),
249 };
250
251 reject_known_github_downloads(&url, input)?;
252 if url.path_segments.len() < 2 {
253 return Err(ParseError::EmptyUrlPath {
254 input: input.to_string(),
255 });
256 }
257 if url.path_segments.get(2).is_some() {
258 return Ok(None);
259 }
260
261 let owner = &url.path_segments[0];
262 let repo = strip_git_suffix(&url.path_segments[1]);
263 Ok(Some(spec_from_git(
264 SourceFormat::GitHubUrl,
265 format!("https://github.com/{owner}/{repo}"),
266 repo,
267 None,
268 version,
269 )))
270}
271
272fn parse_gitlab_tree_url(
273 input: &str,
274 version: Option<String>,
275) -> Result<Option<ParsedSourceSpec>, ParseError> {
276 let url = match parse_http_like_url(input) {
277 Some(url) if looks_like_gitlab_host(&url.host) => url,
278 _ => return Ok(None),
279 };
280
281 let tree_idx = url
282 .path_segments
283 .windows(2)
284 .position(|pair| pair[0] == "-" && pair[1] == "tree");
285 let Some(tree_idx) = tree_idx else {
286 return Ok(None);
287 };
288
289 if tree_idx < 2 || url.path_segments.len() <= tree_idx + 2 {
290 return Err(ParseError::EmptyUrlPath {
291 input: input.to_string(),
292 });
293 }
294
295 let repo_path = &url.path_segments[..tree_idx];
296 let repo = strip_git_suffix(repo_path.last().expect("repo path checked"));
297 let tree_ref = decode_ref_segment(&url.path_segments[tree_idx + 2], input)?;
298 let subpath = normalize_subpath_segments(&url.path_segments[(tree_idx + 3)..])?;
299
300 Ok(Some(spec_from_git(
301 SourceFormat::GitLabUrl,
302 format!("{}://{}/{}", url.scheme, url.authority, repo_path.join("/")),
303 repo,
304 subpath,
305 version.or(Some(tree_ref)),
306 )))
307}
308
309fn parse_gitlab_repo_url(
310 input: &str,
311 version: Option<String>,
312) -> Result<Option<ParsedSourceSpec>, ParseError> {
313 let url = match parse_http_like_url(input) {
314 Some(url) if looks_like_gitlab_host(&url.host) => url,
315 _ => return Ok(None),
316 };
317
318 reject_known_gitlab_downloads(&url, input)?;
319 if url.path_segments.len() < 2 {
320 return Err(ParseError::EmptyUrlPath {
321 input: input.to_string(),
322 });
323 }
324 if url
325 .path_segments
326 .windows(2)
327 .any(|pair| pair[0] == "-" && pair[1] == "tree")
328 {
329 return Ok(None);
330 }
331 if url.path_segments.first().is_some_and(|seg| seg == "api") {
332 return Err(ParseError::UnsupportedSource {
333 input: input.to_string(),
334 reason: "GitLab API endpoints are not supported source inputs".to_string(),
335 });
336 }
337
338 let repo = strip_git_suffix(url.path_segments.last().expect("repo checked"));
339 Ok(Some(spec_from_git(
340 SourceFormat::GitLabUrl,
341 format!(
342 "{}://{}/{}",
343 url.scheme,
344 url.authority,
345 url.path_segments.join("/")
346 ),
347 repo,
348 None,
349 version,
350 )))
351}
352
353fn parse_github_shorthand(
354 input: &str,
355 version: Option<String>,
356) -> Result<Option<ParsedSourceSpec>, ParseError> {
357 if input.contains(':') || input.contains("://") || input.contains('.') {
358 return Ok(None);
359 }
360
361 let segments = collect_non_empty_segments(input);
362 if segments.len() < 2 {
363 return Ok(None);
364 }
365
366 let owner = &segments[0];
367 let repo = strip_git_suffix(&segments[1]);
368 let subpath = normalize_subpath_segments(&segments[2..])?;
369 Ok(Some(spec_from_git(
370 SourceFormat::GitHubShorthand,
371 format!("https://github.com/{owner}/{repo}"),
372 repo,
373 subpath,
374 version,
375 )))
376}
377
378fn parse_generic_git(
379 input: &str,
380 version: Option<String>,
381) -> Result<Option<ParsedSourceSpec>, ParseError> {
382 if is_ssh_shorthand(input) {
383 if !input.contains(':') {
384 return Err(ParseError::MalformedSshUrl {
385 input: input.to_string(),
386 });
387 }
388 let repo = derive_repo_name_from_git(input)?;
389 return Ok(Some(spec_from_git(
390 SourceFormat::GenericGit,
391 input.trim_end_matches('/').to_string(),
392 &repo,
393 None,
394 version,
395 )));
396 }
397
398 let url = match parse_http_like_url(input) {
399 Some(url) => url,
400 None => return Ok(None),
401 };
402
403 if url.scheme == "ssh" || url.scheme == "git" || input.ends_with(".git") {
404 let repo = derive_repo_name_from_segments(&url.path_segments)?;
405 let normalized = format!(
406 "{}://{}/{}",
407 url.scheme,
408 url.authority,
409 url.path_segments.join("/")
410 );
411 return Ok(Some(spec_from_git(
412 SourceFormat::GenericGit,
413 normalized,
414 &repo,
415 None,
416 version,
417 )));
418 }
419
420 Ok(None)
421}
422
423fn reject_unsupported_url(input: &str) -> Result<(), ParseError> {
424 let Some(url) = parse_http_like_url(input) else {
425 return Ok(());
426 };
427
428 if url.path_segments.is_empty() {
429 return Err(ParseError::EmptyUrlPath {
430 input: input.to_string(),
431 });
432 }
433
434 let path = url.path_segments.join("/");
435 let lower = path.to_ascii_lowercase();
436
437 if lower.ends_with(".zip")
438 || lower.ends_with(".tar")
439 || lower.ends_with(".tar.gz")
440 || lower.ends_with(".tgz")
441 || lower.ends_with(".gz")
442 {
443 return Err(ParseError::UnsupportedSource {
444 input: input.to_string(),
445 reason: "archive-download URLs are not supported in v1".to_string(),
446 });
447 }
448
449 if lower.ends_with(".md")
450 || lower.ends_with(".json")
451 || lower.ends_with(".yaml")
452 || lower.ends_with(".yml")
453 {
454 return Err(ParseError::UnsupportedSource {
455 input: input.to_string(),
456 reason: "direct file-download URLs are not supported in v1".to_string(),
457 });
458 }
459
460 if url.host != "github.com"
461 && !looks_like_gitlab_host(&url.host)
462 && !input.ends_with(".git")
463 && url.scheme != "ssh"
464 && url.scheme != "git"
465 {
466 return Err(ParseError::UnsupportedSource {
467 input: input.to_string(),
468 reason: "well-known endpoint URLs are not supported in v1".to_string(),
469 });
470 }
471
472 Ok(())
473}
474
475fn reject_known_github_downloads(url: &ParsedHttpUrl, input: &str) -> Result<(), ParseError> {
476 if url.path_segments.len() >= 3 {
477 let third = url.path_segments[2].as_str();
478 if matches!(third, "releases" | "archive" | "raw" | "blob") {
479 return Err(ParseError::UnsupportedSource {
480 input: input.to_string(),
481 reason: "GitHub download and file URLs are not supported in v1".to_string(),
482 });
483 }
484 }
485 Ok(())
486}
487
488fn reject_known_gitlab_downloads(url: &ParsedHttpUrl, input: &str) -> Result<(), ParseError> {
489 if url
490 .path_segments
491 .windows(2)
492 .any(|pair| pair[0] == "-" && matches!(pair[1].as_str(), "raw" | "archive"))
493 {
494 return Err(ParseError::UnsupportedSource {
495 input: input.to_string(),
496 reason: "GitLab download and file URLs are not supported in v1".to_string(),
497 });
498 }
499 Ok(())
500}
501
502fn parse_http_like_url(input: &str) -> Option<ParsedHttpUrl> {
503 let normalized = if input.starts_with("github.com/") || input.starts_with("gitlab.com/") {
504 format!("https://{input}")
505 } else {
506 input.to_string()
507 };
508
509 let (scheme, rest) = normalized.split_once("://")?;
510 let authority_and_path = rest.trim_start_matches('/');
511 let (authority, path) = authority_and_path
512 .split_once('/')
513 .unwrap_or((authority_and_path, ""));
514 let authority = authority
515 .rsplit_once('@')
516 .map(|(_, host)| host)
517 .unwrap_or(authority);
518 let host = authority.split(':').next().unwrap_or(authority).to_string();
519 let path_segments = collect_non_empty_segments(path);
520
521 Some(ParsedHttpUrl {
522 scheme: scheme.to_string(),
523 host,
524 authority: authority.to_string(),
525 path_segments,
526 })
527}
528
529fn split_fragment(input: &str) -> (&str, Option<String>) {
530 match input.rsplit_once('#') {
531 Some((base, fragment)) if !fragment.is_empty() => (base, Some(fragment.to_string())),
532 _ => (input, None),
533 }
534}
535
536fn split_legacy_version(input: &str) -> (&str, Option<&str>) {
537 let slash_pos = input.rfind('/').unwrap_or(0);
538 match input.rsplit_once('@') {
539 Some((base, suffix)) if !suffix.is_empty() && input.rfind('@').unwrap_or(0) > slash_pos => {
540 (base, Some(suffix))
541 }
542 _ => (input, None),
543 }
544}
545
546fn normalize_subpath_segments(segments: &[String]) -> Result<Option<SourceSubpath>, ParseError> {
547 if segments.is_empty() {
548 return Ok(None);
549 }
550 let raw = segments.join("/");
551 SourceSubpath::new(&raw)
552 .map(Some)
553 .map_err(|err| ParseError::InvalidSubpath {
554 input: raw,
555 reason: err.to_string(),
556 })
557}
558
559fn derive_git_name(repo: &str, subpath: Option<&SourceSubpath>) -> String {
560 match subpath {
561 Some(subpath) => format!("{repo}/{}", subpath.as_str()),
562 None => repo.to_string(),
563 }
564}
565
566fn derive_path_name(path: &Path, subpath: Option<&SourceSubpath>) -> Result<String, ParseError> {
567 let base = path
568 .file_name()
569 .and_then(|name| name.to_str())
570 .filter(|name| !name.is_empty())
571 .ok_or_else(|| ParseError::CannotDeriveName {
572 input: path.display().to_string(),
573 })?;
574 Ok(match subpath {
575 Some(subpath) => format!("{base}/{}", subpath.as_str()),
576 None => base.to_string(),
577 })
578}
579
580fn derive_repo_name_from_git(input: &str) -> Result<String, ParseError> {
581 let (_, repo_path) = input
582 .split_once(':')
583 .ok_or_else(|| ParseError::MalformedSshUrl {
584 input: input.to_string(),
585 })?;
586 let segments = collect_non_empty_segments(repo_path);
587 derive_repo_name_from_segments(&segments)
588}
589
590fn derive_repo_name_from_segments(segments: &[String]) -> Result<String, ParseError> {
591 segments
592 .last()
593 .map(|segment| strip_git_suffix(segment).to_string())
594 .filter(|segment| !segment.is_empty())
595 .ok_or_else(|| ParseError::CannotDeriveName {
596 input: segments.join("/"),
597 })
598}
599
600fn decode_ref_segment(segment: &str, input: &str) -> Result<String, ParseError> {
601 if segment.contains("%2F") || segment.contains("%2f") {
602 return Err(ParseError::SlashyTreeRef {
603 input: input.to_string(),
604 });
605 }
606 Ok(segment.to_string())
607}
608
609fn strip_git_suffix(value: &str) -> &str {
610 value.strip_suffix(".git").unwrap_or(value)
611}
612
613fn looks_like_gitlab_host(host: &str) -> bool {
614 host == "gitlab.com" || host.contains("gitlab")
615}
616
617fn collect_non_empty_segments(input: &str) -> Vec<String> {
618 input
619 .split('/')
620 .filter(|segment| !segment.is_empty())
621 .map(str::to_string)
622 .collect()
623}
624
625fn is_ssh_shorthand(input: &str) -> bool {
626 !input.contains("://")
627 && input.contains('@')
628 && input.contains(':')
629 && input.find('@').unwrap_or(usize::MAX) < input.find(':').unwrap_or(0)
630}
631
632pub fn extract_hostname(input: &str) -> Option<String> {
634 if is_ssh_shorthand(input) {
635 let (user_host, path) = input.split_once(':')?;
636 if path.trim_matches('/').is_empty() {
637 return None;
638 }
639 return user_host.split_once('@').map(|(_, host)| host.to_string());
640 }
641
642 parse_http_like_url(input).map(|url| url.host)
643}
644
645#[cfg(test)]
646mod tests {
647 use super::*;
648 use std::path::Path;
649
650 #[test]
651 fn parse_local_path_wins_before_shorthand() {
652 let parsed = parse("../repo").unwrap();
653 assert_eq!(parsed.format, SourceFormat::LocalPath);
654 assert_eq!(parsed.path.as_deref(), Some(Path::new("../repo")));
655 assert!(parsed.url.is_none());
656 }
657
658 #[test]
659 fn parse_windows_backslash_source_as_local_path() {
660 let parsed = parse("packages\\agents").unwrap();
661 assert_eq!(parsed.format, SourceFormat::LocalPath);
662 assert_eq!(parsed.path.as_deref(), Some(Path::new("packages\\agents")));
663 assert!(parsed.url.is_none());
664 assert_eq!(parsed.name, "packages\\agents");
665 }
666
667 #[test]
668 fn parse_windows_drive_relative_source_as_local_path() {
669 let parsed = parse("C:agents").unwrap();
670 assert_eq!(parsed.format, SourceFormat::LocalPath);
671 assert_eq!(parsed.path.as_deref(), Some(Path::new("C:agents")));
672 assert!(parsed.url.is_none());
673 assert_eq!(parsed.name, "C:agents");
674 }
675
676 #[test]
677 fn parse_windows_extended_path_remains_unsupported() {
678 let err = parse("\\\\?\\C:\\agents").unwrap_err();
679 assert!(matches!(err, ParseError::UnrecognizedFormat { .. }));
680 }
681
682 #[test]
683 fn parse_github_shorthand_with_subpath() {
684 let parsed = parse("owner/repo/plugins/foo").unwrap();
685 assert_eq!(parsed.format, SourceFormat::GitHubShorthand);
686 assert_eq!(parsed.url.as_deref(), Some("https://github.com/owner/repo"));
687 assert_eq!(
688 parsed.subpath.as_ref().map(SourceSubpath::as_str),
689 Some("plugins/foo")
690 );
691 assert_eq!(parsed.name, "repo/plugins/foo");
692 }
693
694 #[test]
695 fn parse_github_alias_with_subpath() {
696 let parsed = parse("github:owner/repo/plugins/foo").unwrap();
697 assert_eq!(parsed.format, SourceFormat::GitHubAlias);
698 assert_eq!(parsed.url.as_deref(), Some("https://github.com/owner/repo"));
699 assert_eq!(
700 parsed.subpath.as_ref().map(SourceSubpath::as_str),
701 Some("plugins/foo")
702 );
703 }
704
705 #[test]
706 fn parse_github_tree_url_with_ref_and_subpath() {
707 let parsed = parse("https://github.com/owner/repo/tree/main/plugins/foo").unwrap();
708 assert_eq!(parsed.format, SourceFormat::GitHubUrl);
709 assert_eq!(parsed.url.as_deref(), Some("https://github.com/owner/repo"));
710 assert_eq!(parsed.version.as_deref(), Some("main"));
711 assert_eq!(
712 parsed.subpath.as_ref().map(SourceSubpath::as_str),
713 Some("plugins/foo")
714 );
715 }
716
717 #[test]
718 fn parse_gitlab_alias_preserves_repo_coordinate_only() {
719 let parsed = parse("gitlab:group/subgroup/repo").unwrap();
720 assert_eq!(parsed.format, SourceFormat::GitLabAlias);
721 assert_eq!(
722 parsed.url.as_deref(),
723 Some("https://gitlab.com/group/subgroup/repo")
724 );
725 assert!(parsed.subpath.is_none());
726 assert_eq!(parsed.name, "repo");
727 }
728
729 #[test]
730 fn parse_gitlab_tree_url_custom_host() {
731 let parsed =
732 parse("https://gitlab.example.com/group/subgroup/repo/-/tree/main/plugins/foo")
733 .unwrap();
734 assert_eq!(parsed.format, SourceFormat::GitLabUrl);
735 assert_eq!(
736 parsed.url.as_deref(),
737 Some("https://gitlab.example.com/group/subgroup/repo")
738 );
739 assert_eq!(parsed.version.as_deref(), Some("main"));
740 assert_eq!(
741 parsed.subpath.as_ref().map(SourceSubpath::as_str),
742 Some("plugins/foo")
743 );
744 }
745
746 #[test]
747 fn parse_gitlab_plain_repo_url_custom_host() {
748 let parsed = parse("https://gitlab.example.com/group/subgroup/repo").unwrap();
749 assert_eq!(parsed.format, SourceFormat::GitLabUrl);
750 assert_eq!(
751 parsed.url.as_deref(),
752 Some("https://gitlab.example.com/group/subgroup/repo")
753 );
754 assert!(parsed.subpath.is_none());
755 assert_eq!(parsed.name, "repo");
756 }
757
758 #[test]
759 fn parse_gitlab_repo_url_preserves_explicit_port() {
760 let parsed = parse("git://gitlab.localtest.me:19424/group/pkg.git").unwrap();
761 assert_eq!(parsed.format, SourceFormat::GitLabUrl);
762 assert_eq!(
763 parsed.url.as_deref(),
764 Some("git://gitlab.localtest.me:19424/group/pkg.git")
765 );
766 assert!(parsed.subpath.is_none());
767 assert_eq!(parsed.name, "pkg");
768 }
769
770 #[test]
771 fn parse_generic_git_ssh_source() {
772 let parsed = parse("git@example.com:org/repo.git").unwrap();
773 assert_eq!(parsed.format, SourceFormat::GenericGit);
774 assert_eq!(parsed.url.as_deref(), Some("git@example.com:org/repo.git"));
775 assert!(parsed.subpath.is_none());
776 assert_eq!(parsed.name, "repo");
777 }
778
779 #[test]
780 fn parse_generic_git_preserves_explicit_port() {
781 let parsed = parse("git://127.0.0.1:19421/group/pkg.git").unwrap();
782 assert_eq!(parsed.format, SourceFormat::GenericGit);
783 assert_eq!(
784 parsed.url.as_deref(),
785 Some("git://127.0.0.1:19421/group/pkg.git")
786 );
787 assert!(parsed.subpath.is_none());
788 assert_eq!(parsed.name, "pkg");
789 }
790
791 #[test]
792 fn parse_fragment_ref_beats_legacy_at_version() {
793 let parsed = parse("owner/repo#feature/x").unwrap();
794 assert_eq!(parsed.version.as_deref(), Some("feature/x"));
795 assert_eq!(parsed.url.as_deref(), Some("https://github.com/owner/repo"));
796 }
797
798 #[test]
799 fn parse_legacy_at_version_still_supported() {
800 let parsed = parse("owner/repo@v1.2.3").unwrap();
801 assert_eq!(parsed.version.as_deref(), Some("v1.2.3"));
802 assert_eq!(parsed.url.as_deref(), Some("https://github.com/owner/repo"));
803 }
804
805 #[test]
806 fn rejects_archive_download_url() {
807 let err = parse("https://github.com/owner/repo/archive/refs/heads/main.zip").unwrap_err();
808 assert!(matches!(err, ParseError::UnsupportedSource { .. }));
809 }
810
811 #[test]
812 fn rejects_file_download_url() {
813 let err = parse("https://raw.githubusercontent.com/owner/repo/main/SKILL.md").unwrap_err();
814 assert!(matches!(err, ParseError::UnsupportedSource { .. }));
815 }
816
817 #[test]
818 fn rejects_slashy_tree_ref_when_encoded() {
819 let err = parse("https://github.com/owner/repo/tree/feature%2Fx/plugins/foo").unwrap_err();
820 assert!(matches!(err, ParseError::SlashyTreeRef { .. }));
821 }
822
823 #[test]
824 fn extract_hostname_supports_ssh_and_https() {
825 assert_eq!(
826 extract_hostname("git@example.com:org/repo.git").as_deref(),
827 Some("example.com")
828 );
829 assert_eq!(
830 extract_hostname("https://github.com/owner/repo").as_deref(),
831 Some("github.com")
832 );
833 }
834}