Skip to main content

mars_agents/source/
parse.rs

1use std::path::{Path, PathBuf};
2
3use crate::types::{SourceSubpath, SourceUrl};
4
5/// Classification of source input syntax.
6#[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/// Structured result of parsing a CLI source specifier.
18#[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/// Errors raised while parsing a source specifier.
30#[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
66/// Parse a source specifier into a normalized structured value.
67pub 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
650/// Extract hostname from a URL-like git source string.
651pub 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}