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    // Split on both `/` and `\` to handle Windows paths even when running on
568    // Linux (where Path only recognizes `/`).  Also strip Windows drive prefixes
569    // like `C:` that appear in drive-relative paths.
570    let path_str = path.to_string_lossy();
571    let base = path_str
572        .rsplit(['/', '\\'])
573        .find(|s| !s.is_empty())
574        .map(|s| {
575            // Strip drive letter prefix (e.g. "C:agents" → "agents")
576            if s.len() >= 2 && s.as_bytes()[0].is_ascii_alphabetic() && s.as_bytes()[1] == b':' {
577                &s[2..]
578            } else {
579                s
580            }
581        })
582        .filter(|name| !name.is_empty())
583        .ok_or_else(|| ParseError::CannotDeriveName {
584            input: path.display().to_string(),
585        })?;
586    Ok(match subpath {
587        Some(subpath) => format!("{base}/{}", subpath.as_str()),
588        None => base.to_string(),
589    })
590}
591
592fn derive_repo_name_from_git(input: &str) -> Result<String, ParseError> {
593    let (_, repo_path) = input
594        .split_once(':')
595        .ok_or_else(|| ParseError::MalformedSshUrl {
596            input: input.to_string(),
597        })?;
598    let segments = collect_non_empty_segments(repo_path);
599    derive_repo_name_from_segments(&segments)
600}
601
602fn derive_repo_name_from_segments(segments: &[String]) -> Result<String, ParseError> {
603    segments
604        .last()
605        .map(|segment| strip_git_suffix(segment).to_string())
606        .filter(|segment| !segment.is_empty())
607        .ok_or_else(|| ParseError::CannotDeriveName {
608            input: segments.join("/"),
609        })
610}
611
612fn decode_ref_segment(segment: &str, input: &str) -> Result<String, ParseError> {
613    if segment.contains("%2F") || segment.contains("%2f") {
614        return Err(ParseError::SlashyTreeRef {
615            input: input.to_string(),
616        });
617    }
618    Ok(segment.to_string())
619}
620
621fn strip_git_suffix(value: &str) -> &str {
622    value.strip_suffix(".git").unwrap_or(value)
623}
624
625fn looks_like_gitlab_host(host: &str) -> bool {
626    host == "gitlab.com" || host.contains("gitlab")
627}
628
629fn collect_non_empty_segments(input: &str) -> Vec<String> {
630    input
631        .split('/')
632        .filter(|segment| !segment.is_empty())
633        .map(str::to_string)
634        .collect()
635}
636
637fn is_ssh_shorthand(input: &str) -> bool {
638    !input.contains("://")
639        && input.contains('@')
640        && input.contains(':')
641        && input.find('@').unwrap_or(usize::MAX) < input.find(':').unwrap_or(0)
642}
643
644/// Extract hostname from a URL-like git source string.
645pub fn extract_hostname(input: &str) -> Option<String> {
646    if is_ssh_shorthand(input) {
647        let (user_host, path) = input.split_once(':')?;
648        if path.trim_matches('/').is_empty() {
649            return None;
650        }
651        return user_host.split_once('@').map(|(_, host)| host.to_string());
652    }
653
654    parse_http_like_url(input).map(|url| url.host)
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660    use std::path::Path;
661
662    #[test]
663    fn parse_local_path_wins_before_shorthand() {
664        let parsed = parse("../repo").unwrap();
665        assert_eq!(parsed.format, SourceFormat::LocalPath);
666        assert_eq!(parsed.path.as_deref(), Some(Path::new("../repo")));
667        assert!(parsed.url.is_none());
668    }
669
670    #[test]
671    fn parse_windows_backslash_source_as_local_path() {
672        let parsed = parse("packages\\agents").unwrap();
673        assert_eq!(parsed.format, SourceFormat::LocalPath);
674        assert_eq!(parsed.path.as_deref(), Some(Path::new("packages\\agents")));
675        assert!(parsed.url.is_none());
676        // Name is the last path component, not the full path
677        assert_eq!(parsed.name, "agents");
678    }
679
680    #[test]
681    fn parse_windows_drive_relative_source_as_local_path() {
682        let parsed = parse("C:agents").unwrap();
683        assert_eq!(parsed.format, SourceFormat::LocalPath);
684        assert_eq!(parsed.path.as_deref(), Some(Path::new("C:agents")));
685        assert!(parsed.url.is_none());
686        // Name is the last path component, not the drive-relative form
687        assert_eq!(parsed.name, "agents");
688    }
689
690    #[test]
691    fn parse_windows_extended_path_remains_unsupported() {
692        let err = parse("\\\\?\\C:\\agents").unwrap_err();
693        assert!(matches!(err, ParseError::UnrecognizedFormat { .. }));
694    }
695
696    #[test]
697    fn parse_github_shorthand_with_subpath() {
698        let parsed = parse("owner/repo/plugins/foo").unwrap();
699        assert_eq!(parsed.format, SourceFormat::GitHubShorthand);
700        assert_eq!(parsed.url.as_deref(), Some("https://github.com/owner/repo"));
701        assert_eq!(
702            parsed.subpath.as_ref().map(SourceSubpath::as_str),
703            Some("plugins/foo")
704        );
705        assert_eq!(parsed.name, "repo/plugins/foo");
706    }
707
708    #[test]
709    fn parse_github_alias_with_subpath() {
710        let parsed = parse("github:owner/repo/plugins/foo").unwrap();
711        assert_eq!(parsed.format, SourceFormat::GitHubAlias);
712        assert_eq!(parsed.url.as_deref(), Some("https://github.com/owner/repo"));
713        assert_eq!(
714            parsed.subpath.as_ref().map(SourceSubpath::as_str),
715            Some("plugins/foo")
716        );
717    }
718
719    #[test]
720    fn parse_github_tree_url_with_ref_and_subpath() {
721        let parsed = parse("https://github.com/owner/repo/tree/main/plugins/foo").unwrap();
722        assert_eq!(parsed.format, SourceFormat::GitHubUrl);
723        assert_eq!(parsed.url.as_deref(), Some("https://github.com/owner/repo"));
724        assert_eq!(parsed.version.as_deref(), Some("main"));
725        assert_eq!(
726            parsed.subpath.as_ref().map(SourceSubpath::as_str),
727            Some("plugins/foo")
728        );
729    }
730
731    #[test]
732    fn parse_gitlab_alias_preserves_repo_coordinate_only() {
733        let parsed = parse("gitlab:group/subgroup/repo").unwrap();
734        assert_eq!(parsed.format, SourceFormat::GitLabAlias);
735        assert_eq!(
736            parsed.url.as_deref(),
737            Some("https://gitlab.com/group/subgroup/repo")
738        );
739        assert!(parsed.subpath.is_none());
740        assert_eq!(parsed.name, "repo");
741    }
742
743    #[test]
744    fn parse_gitlab_tree_url_custom_host() {
745        let parsed =
746            parse("https://gitlab.example.com/group/subgroup/repo/-/tree/main/plugins/foo")
747                .unwrap();
748        assert_eq!(parsed.format, SourceFormat::GitLabUrl);
749        assert_eq!(
750            parsed.url.as_deref(),
751            Some("https://gitlab.example.com/group/subgroup/repo")
752        );
753        assert_eq!(parsed.version.as_deref(), Some("main"));
754        assert_eq!(
755            parsed.subpath.as_ref().map(SourceSubpath::as_str),
756            Some("plugins/foo")
757        );
758    }
759
760    #[test]
761    fn parse_gitlab_plain_repo_url_custom_host() {
762        let parsed = parse("https://gitlab.example.com/group/subgroup/repo").unwrap();
763        assert_eq!(parsed.format, SourceFormat::GitLabUrl);
764        assert_eq!(
765            parsed.url.as_deref(),
766            Some("https://gitlab.example.com/group/subgroup/repo")
767        );
768        assert!(parsed.subpath.is_none());
769        assert_eq!(parsed.name, "repo");
770    }
771
772    #[test]
773    fn parse_gitlab_repo_url_preserves_explicit_port() {
774        let parsed = parse("git://gitlab.localtest.me:19424/group/pkg.git").unwrap();
775        assert_eq!(parsed.format, SourceFormat::GitLabUrl);
776        assert_eq!(
777            parsed.url.as_deref(),
778            Some("git://gitlab.localtest.me:19424/group/pkg.git")
779        );
780        assert!(parsed.subpath.is_none());
781        assert_eq!(parsed.name, "pkg");
782    }
783
784    #[test]
785    fn parse_generic_git_ssh_source() {
786        let parsed = parse("git@example.com:org/repo.git").unwrap();
787        assert_eq!(parsed.format, SourceFormat::GenericGit);
788        assert_eq!(parsed.url.as_deref(), Some("git@example.com:org/repo.git"));
789        assert!(parsed.subpath.is_none());
790        assert_eq!(parsed.name, "repo");
791    }
792
793    #[test]
794    fn parse_generic_git_preserves_explicit_port() {
795        let parsed = parse("git://127.0.0.1:19421/group/pkg.git").unwrap();
796        assert_eq!(parsed.format, SourceFormat::GenericGit);
797        assert_eq!(
798            parsed.url.as_deref(),
799            Some("git://127.0.0.1:19421/group/pkg.git")
800        );
801        assert!(parsed.subpath.is_none());
802        assert_eq!(parsed.name, "pkg");
803    }
804
805    #[test]
806    fn parse_fragment_ref_beats_legacy_at_version() {
807        let parsed = parse("owner/repo#feature/x").unwrap();
808        assert_eq!(parsed.version.as_deref(), Some("feature/x"));
809        assert_eq!(parsed.url.as_deref(), Some("https://github.com/owner/repo"));
810    }
811
812    #[test]
813    fn parse_legacy_at_version_still_supported() {
814        let parsed = parse("owner/repo@v1.2.3").unwrap();
815        assert_eq!(parsed.version.as_deref(), Some("v1.2.3"));
816        assert_eq!(parsed.url.as_deref(), Some("https://github.com/owner/repo"));
817    }
818
819    #[test]
820    fn rejects_archive_download_url() {
821        let err = parse("https://github.com/owner/repo/archive/refs/heads/main.zip").unwrap_err();
822        assert!(matches!(err, ParseError::UnsupportedSource { .. }));
823    }
824
825    #[test]
826    fn rejects_file_download_url() {
827        let err = parse("https://raw.githubusercontent.com/owner/repo/main/SKILL.md").unwrap_err();
828        assert!(matches!(err, ParseError::UnsupportedSource { .. }));
829    }
830
831    #[test]
832    fn rejects_slashy_tree_ref_when_encoded() {
833        let err = parse("https://github.com/owner/repo/tree/feature%2Fx/plugins/foo").unwrap_err();
834        assert!(matches!(err, ParseError::SlashyTreeRef { .. }));
835    }
836
837    #[test]
838    fn extract_hostname_supports_ssh_and_https() {
839        assert_eq!(
840            extract_hostname("git@example.com:org/repo.git").as_deref(),
841            Some("example.com")
842        );
843        assert_eq!(
844            extract_hostname("https://github.com/owner/repo").as_deref(),
845            Some("github.com")
846        );
847    }
848}