Skip to main content

mars_agents/source/
parse.rs

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