1use std::path::{Path, PathBuf};
2
3use crate::types::{SourceSubpath, SourceUrl};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum SourceFormat {
8 LocalPath,
9 GitHubShorthand,
10 GitHubAlias,
11 GitHubUrl,
12 GitLabAlias,
13 GitLabUrl,
14 GenericGit,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct ParsedSourceSpec {
20 pub format: SourceFormat,
21 pub raw: String,
22 pub url: Option<SourceUrl>,
23 pub path: Option<PathBuf>,
24 pub subpath: Option<SourceSubpath>,
25 pub version: Option<String>,
26 pub name: String,
27}
28
29#[derive(Debug, thiserror::Error, PartialEq, Eq)]
31pub enum ParseError {
32 #[error(
33 "cannot determine source type for {input:?} — expected a local path, supported git source, or owner/repo shorthand"
34 )]
35 UnrecognizedFormat { input: String },
36
37 #[error("unsupported source form for v1: {input:?} ({reason})")]
38 UnsupportedSource { input: String, reason: String },
39
40 #[error("SSH URL {input:?} is missing the colon-separated path (expected git@host:owner/repo)")]
41 MalformedSshUrl { input: String },
42
43 #[error("cannot derive a name from {input:?}")]
44 CannotDeriveName { input: String },
45
46 #[error("URL {input:?} has no repository path component")]
47 EmptyUrlPath { input: String },
48
49 #[error("invalid subpath {input:?}: {reason}")]
50 InvalidSubpath { input: String, reason: String },
51
52 #[error(
53 "tree URL {input:?} uses a slashy branch name that is ambiguous in the path; use the equivalent #ref form instead"
54 )]
55 SlashyTreeRef { input: String },
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59struct ParsedHttpUrl {
60 scheme: String,
61 host: String,
62 authority: String,
63 path_segments: Vec<String>,
64}
65
66pub fn parse(input: &str) -> Result<ParsedSourceSpec, ParseError> {
68 let trimmed = input.trim();
69 if trimmed.is_empty() {
70 return Err(ParseError::UnrecognizedFormat {
71 input: input.to_string(),
72 });
73 }
74
75 if is_local_path(trimmed) {
76 let path = PathBuf::from(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_local_path(input: &str) -> bool {
626 input == "."
627 || input == ".."
628 || input.starts_with("./")
629 || input.starts_with("../")
630 || input.starts_with('/')
631 || input.starts_with('~')
632 || is_windows_drive_path(input)
633}
634
635fn is_windows_drive_path(input: &str) -> bool {
636 let bytes = input.as_bytes();
637 bytes.len() >= 3
638 && bytes[0].is_ascii_alphabetic()
639 && bytes[1] == b':'
640 && matches!(bytes[2], b'\\' | b'/')
641}
642
643fn is_ssh_shorthand(input: &str) -> bool {
644 !input.contains("://")
645 && input.contains('@')
646 && input.contains(':')
647 && input.find('@').unwrap_or(usize::MAX) < input.find(':').unwrap_or(0)
648}
649
650pub fn extract_hostname(input: &str) -> Option<String> {
652 if is_ssh_shorthand(input) {
653 let (user_host, path) = input.split_once(':')?;
654 if path.trim_matches('/').is_empty() {
655 return None;
656 }
657 return user_host.split_once('@').map(|(_, host)| host.to_string());
658 }
659
660 parse_http_like_url(input).map(|url| url.host)
661}
662
663#[cfg(test)]
664mod tests {
665 use super::*;
666 use std::path::Path;
667
668 #[test]
669 fn parse_local_path_wins_before_shorthand() {
670 let parsed = parse("../repo").unwrap();
671 assert_eq!(parsed.format, SourceFormat::LocalPath);
672 assert_eq!(parsed.path.as_deref(), Some(Path::new("../repo")));
673 assert!(parsed.url.is_none());
674 }
675
676 #[test]
677 fn parse_github_shorthand_with_subpath() {
678 let parsed = parse("owner/repo/plugins/foo").unwrap();
679 assert_eq!(parsed.format, SourceFormat::GitHubShorthand);
680 assert_eq!(parsed.url.as_deref(), Some("https://github.com/owner/repo"));
681 assert_eq!(
682 parsed.subpath.as_ref().map(SourceSubpath::as_str),
683 Some("plugins/foo")
684 );
685 assert_eq!(parsed.name, "repo/plugins/foo");
686 }
687
688 #[test]
689 fn parse_github_alias_with_subpath() {
690 let parsed = parse("github:owner/repo/plugins/foo").unwrap();
691 assert_eq!(parsed.format, SourceFormat::GitHubAlias);
692 assert_eq!(parsed.url.as_deref(), Some("https://github.com/owner/repo"));
693 assert_eq!(
694 parsed.subpath.as_ref().map(SourceSubpath::as_str),
695 Some("plugins/foo")
696 );
697 }
698
699 #[test]
700 fn parse_github_tree_url_with_ref_and_subpath() {
701 let parsed = parse("https://github.com/owner/repo/tree/main/plugins/foo").unwrap();
702 assert_eq!(parsed.format, SourceFormat::GitHubUrl);
703 assert_eq!(parsed.url.as_deref(), Some("https://github.com/owner/repo"));
704 assert_eq!(parsed.version.as_deref(), Some("main"));
705 assert_eq!(
706 parsed.subpath.as_ref().map(SourceSubpath::as_str),
707 Some("plugins/foo")
708 );
709 }
710
711 #[test]
712 fn parse_gitlab_alias_preserves_repo_coordinate_only() {
713 let parsed = parse("gitlab:group/subgroup/repo").unwrap();
714 assert_eq!(parsed.format, SourceFormat::GitLabAlias);
715 assert_eq!(
716 parsed.url.as_deref(),
717 Some("https://gitlab.com/group/subgroup/repo")
718 );
719 assert!(parsed.subpath.is_none());
720 assert_eq!(parsed.name, "repo");
721 }
722
723 #[test]
724 fn parse_gitlab_tree_url_custom_host() {
725 let parsed =
726 parse("https://gitlab.example.com/group/subgroup/repo/-/tree/main/plugins/foo")
727 .unwrap();
728 assert_eq!(parsed.format, SourceFormat::GitLabUrl);
729 assert_eq!(
730 parsed.url.as_deref(),
731 Some("https://gitlab.example.com/group/subgroup/repo")
732 );
733 assert_eq!(parsed.version.as_deref(), Some("main"));
734 assert_eq!(
735 parsed.subpath.as_ref().map(SourceSubpath::as_str),
736 Some("plugins/foo")
737 );
738 }
739
740 #[test]
741 fn parse_gitlab_plain_repo_url_custom_host() {
742 let parsed = parse("https://gitlab.example.com/group/subgroup/repo").unwrap();
743 assert_eq!(parsed.format, SourceFormat::GitLabUrl);
744 assert_eq!(
745 parsed.url.as_deref(),
746 Some("https://gitlab.example.com/group/subgroup/repo")
747 );
748 assert!(parsed.subpath.is_none());
749 assert_eq!(parsed.name, "repo");
750 }
751
752 #[test]
753 fn parse_gitlab_repo_url_preserves_explicit_port() {
754 let parsed = parse("git://gitlab.localtest.me:19424/group/pkg.git").unwrap();
755 assert_eq!(parsed.format, SourceFormat::GitLabUrl);
756 assert_eq!(
757 parsed.url.as_deref(),
758 Some("git://gitlab.localtest.me:19424/group/pkg.git")
759 );
760 assert!(parsed.subpath.is_none());
761 assert_eq!(parsed.name, "pkg");
762 }
763
764 #[test]
765 fn parse_generic_git_ssh_source() {
766 let parsed = parse("git@example.com:org/repo.git").unwrap();
767 assert_eq!(parsed.format, SourceFormat::GenericGit);
768 assert_eq!(parsed.url.as_deref(), Some("git@example.com:org/repo.git"));
769 assert!(parsed.subpath.is_none());
770 assert_eq!(parsed.name, "repo");
771 }
772
773 #[test]
774 fn parse_generic_git_preserves_explicit_port() {
775 let parsed = parse("git://127.0.0.1:19421/group/pkg.git").unwrap();
776 assert_eq!(parsed.format, SourceFormat::GenericGit);
777 assert_eq!(
778 parsed.url.as_deref(),
779 Some("git://127.0.0.1:19421/group/pkg.git")
780 );
781 assert!(parsed.subpath.is_none());
782 assert_eq!(parsed.name, "pkg");
783 }
784
785 #[test]
786 fn parse_fragment_ref_beats_legacy_at_version() {
787 let parsed = parse("owner/repo#feature/x").unwrap();
788 assert_eq!(parsed.version.as_deref(), Some("feature/x"));
789 assert_eq!(parsed.url.as_deref(), Some("https://github.com/owner/repo"));
790 }
791
792 #[test]
793 fn parse_legacy_at_version_still_supported() {
794 let parsed = parse("owner/repo@v1.2.3").unwrap();
795 assert_eq!(parsed.version.as_deref(), Some("v1.2.3"));
796 assert_eq!(parsed.url.as_deref(), Some("https://github.com/owner/repo"));
797 }
798
799 #[test]
800 fn rejects_archive_download_url() {
801 let err = parse("https://github.com/owner/repo/archive/refs/heads/main.zip").unwrap_err();
802 assert!(matches!(err, ParseError::UnsupportedSource { .. }));
803 }
804
805 #[test]
806 fn rejects_file_download_url() {
807 let err = parse("https://raw.githubusercontent.com/owner/repo/main/SKILL.md").unwrap_err();
808 assert!(matches!(err, ParseError::UnsupportedSource { .. }));
809 }
810
811 #[test]
812 fn rejects_slashy_tree_ref_when_encoded() {
813 let err = parse("https://github.com/owner/repo/tree/feature%2Fx/plugins/foo").unwrap_err();
814 assert!(matches!(err, ParseError::SlashyTreeRef { .. }));
815 }
816
817 #[test]
818 fn extract_hostname_supports_ssh_and_https() {
819 assert_eq!(
820 extract_hostname("git@example.com:org/repo.git").as_deref(),
821 Some("example.com")
822 );
823 assert_eq!(
824 extract_hostname("https://github.com/owner/repo").as_deref(),
825 Some("github.com")
826 );
827 }
828}