Skip to main content

lean_ctx/core/git/
repo_url.rs

1//! Repository URL detection & parsing.
2//!
3//! Turns an agent-supplied URL (a repo root, or a web `blob`/`tree` link) into a
4//! structured [`RepoRef`] with a canonical https clone URL plus the optional
5//! `ref` + in-repo `subpath` carried by the web form. https-only by design — the
6//! clone step ([`super::clone`]) additionally SSRF-guards the host.
7//!
8//! Handles the three common forgejo/forge layouts:
9//! * GitHub / Gitea / Bitbucket: `owner/repo/blob/<ref>/<path>` (and `/tree/`)
10//! * GitLab (incl. nested groups): `group/.../repo/-/blob/<ref>/<path>`
11//! * bare repo roots: `owner/repo` (optionally `.git`)
12
13/// A parsed repository reference.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct RepoRef {
16    /// Host, e.g. `github.com`.
17    pub host: String,
18    /// Namespace before the repo name (may contain `/` for GitLab subgroups).
19    pub owner: String,
20    /// Repository name without a `.git` suffix.
21    pub repo: String,
22    /// Canonical https clone URL: `https://<host>/<owner>/<repo>.git`.
23    pub clone_url: String,
24    /// Branch / tag / commit carried by a web `blob`/`tree` URL, if any.
25    pub git_ref: Option<String>,
26    /// In-repo path carried by a web `blob`/`tree` URL, if any.
27    pub subpath: Option<String>,
28}
29
30impl RepoRef {
31    /// `owner/repo` (namespace + name), the human project path.
32    pub fn project_path(&self) -> String {
33        format!("{}/{}", self.owner, self.repo)
34    }
35
36    /// A stable cache slug, filesystem-safe: `host/owner/repo` with the owner's
37    /// internal slashes preserved as nested dirs.
38    pub fn cache_slug(&self) -> String {
39        format!("{}/{}/{}", self.host, self.owner, self.repo)
40    }
41}
42
43/// Parse a repository URL, or return `None` when it is not an https repo URL.
44pub fn parse(url: &str) -> Option<RepoRef> {
45    let rest = url.trim().strip_prefix("https://")?;
46    let (host, path) = rest.split_once('/')?;
47    if !is_valid_host(host) {
48        return None;
49    }
50    // Drop query string / fragment, normalize slashes.
51    let path = path.split(['?', '#']).next().unwrap_or("");
52    let path = path.trim_matches('/');
53    if path.is_empty() {
54        return None;
55    }
56
57    let (project_path, git_ref, subpath) = split_project_and_location(path);
58    let project_path = project_path.trim_end_matches('/').trim_end_matches(".git");
59
60    let segments: Vec<&str> = project_path.split('/').filter(|s| !s.is_empty()).collect();
61    if segments.len() < 2 {
62        return None; // need at least owner + repo
63    }
64    let repo = (*segments.last()?).to_string();
65    let owner = segments[..segments.len() - 1].join("/");
66    if owner.is_empty() || repo.is_empty() {
67        return None;
68    }
69
70    let clone_url = format!("https://{host}/{owner}/{repo}.git");
71    Some(RepoRef {
72        host: host.to_string(),
73        owner,
74        repo,
75        clone_url,
76        git_ref,
77        subpath,
78    })
79}
80
81/// Split a path into `(project_path, ref, subpath)`, recognizing both the GitLab
82/// `/-/blob|tree/` separator and the GitHub/Gitea `/blob|tree/` third segment.
83fn split_project_and_location(path: &str) -> (String, Option<String>, Option<String>) {
84    // GitLab: everything before `/-/` is the (possibly nested) project path.
85    if let Some((proj, tail)) = path.split_once("/-/") {
86        let (git_ref, subpath) = parse_location_tail(tail);
87        return (proj.to_string(), git_ref, subpath);
88    }
89
90    // GitHub/Gitea/Bitbucket: `owner/repo/blob|tree/<ref>/<path>`. Only treat
91    // `blob`/`tree` as a separator at segment index 2 so a repo can't be hidden
92    // by a same-named path component.
93    let segs: Vec<&str> = path.split('/').collect();
94    if segs.len() >= 4 && matches!(segs[2], "blob" | "tree" | "raw" | "src" | "commits") {
95        let project = segs[..2].join("/");
96        let tail = segs[3..].join("/");
97        let (git_ref, subpath) = parse_ref_then_path(&tail);
98        return (project, git_ref, subpath);
99    }
100
101    (path.to_string(), None, None)
102}
103
104/// Parse a `blob/<ref>/<path>` style tail (the part after GitLab's `/-/`).
105fn parse_location_tail(tail: &str) -> (Option<String>, Option<String>) {
106    let segs: Vec<&str> = tail.split('/').filter(|s| !s.is_empty()).collect();
107    if segs.is_empty() {
108        return (None, None);
109    }
110    // segs[0] is the kind (blob/tree/raw); the rest is `<ref>/<path>`.
111    let after_kind = if matches!(segs[0], "blob" | "tree" | "raw") {
112        &segs[1..]
113    } else {
114        &segs[..]
115    };
116    parse_ref_then_path(&after_kind.join("/"))
117}
118
119/// Split `<ref>/<path>` taking the first segment as the ref. Branch names with
120/// slashes can't be disambiguated from a URL alone — callers may pass `ref`
121/// explicitly to override.
122fn parse_ref_then_path(s: &str) -> (Option<String>, Option<String>) {
123    let s = s.trim_matches('/');
124    if s.is_empty() {
125        return (None, None);
126    }
127    match s.split_once('/') {
128        Some((r, p)) if !p.is_empty() => (Some(r.to_string()), Some(p.to_string())),
129        _ => (Some(s.to_string()), None),
130    }
131}
132
133/// A plausible DNS host: has a dot, no spaces/auth markers, not just a port.
134fn is_valid_host(host: &str) -> bool {
135    !host.is_empty()
136        && host.contains('.')
137        && !host.contains(' ')
138        && !host.contains('@')
139        && !host.starts_with('.')
140        && !host.ends_with('.')
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn parses_bare_repo_root() {
149        let r = parse("https://github.com/yvgude/lean-ctx").unwrap();
150        assert_eq!(r.host, "github.com");
151        assert_eq!(r.owner, "yvgude");
152        assert_eq!(r.repo, "lean-ctx");
153        assert_eq!(r.clone_url, "https://github.com/yvgude/lean-ctx.git");
154        assert_eq!(r.git_ref, None);
155        assert_eq!(r.subpath, None);
156    }
157
158    #[test]
159    fn strips_dot_git_and_trailing_slash() {
160        let r = parse("https://github.com/o/r.git/").unwrap();
161        assert_eq!(r.repo, "r");
162        assert_eq!(r.clone_url, "https://github.com/o/r.git");
163    }
164
165    #[test]
166    fn parses_github_blob_ref_and_subpath() {
167        let r = parse("https://github.com/yvgude/lean-ctx/blob/main/src/core/mod.rs").unwrap();
168        assert_eq!(r.project_path(), "yvgude/lean-ctx");
169        assert_eq!(r.git_ref.as_deref(), Some("main"));
170        assert_eq!(r.subpath.as_deref(), Some("src/core/mod.rs"));
171        assert_eq!(r.clone_url, "https://github.com/yvgude/lean-ctx.git");
172    }
173
174    #[test]
175    fn parses_github_tree_ref_only() {
176        let r = parse("https://github.com/o/r/tree/v1.2.3").unwrap();
177        assert_eq!(r.git_ref.as_deref(), Some("v1.2.3"));
178        assert_eq!(r.subpath, None);
179    }
180
181    #[test]
182    fn parses_gitlab_dash_blob_with_nested_group() {
183        let r = parse("https://gitlab.com/group/sub/proj/-/blob/main/a/b.rs").unwrap();
184        assert_eq!(r.host, "gitlab.com");
185        assert_eq!(r.owner, "group/sub");
186        assert_eq!(r.repo, "proj");
187        assert_eq!(r.git_ref.as_deref(), Some("main"));
188        assert_eq!(r.subpath.as_deref(), Some("a/b.rs"));
189        assert_eq!(r.clone_url, "https://gitlab.com/group/sub/proj.git");
190    }
191
192    #[test]
193    fn parses_gitlab_tree_dir() {
194        let r = parse("https://gitlab.com/o/r/-/tree/dev/src").unwrap();
195        assert_eq!(r.git_ref.as_deref(), Some("dev"));
196        assert_eq!(r.subpath.as_deref(), Some("src"));
197    }
198
199    #[test]
200    fn drops_query_and_fragment() {
201        let r = parse("https://github.com/o/r/blob/main/x.rs?plain=1#L10").unwrap();
202        assert_eq!(r.subpath.as_deref(), Some("x.rs"));
203    }
204
205    #[test]
206    fn rejects_non_https_and_garbage() {
207        assert!(parse("http://github.com/o/r").is_none());
208        assert!(parse("git@github.com:o/r.git").is_none());
209        assert!(parse("https://github.com/justowner").is_none());
210        assert!(parse("https://localhost/o/r").is_none()); // no dot in host
211        assert!(parse("not a url").is_none());
212    }
213
214    #[test]
215    fn cache_slug_is_filesystem_nested() {
216        let r = parse("https://gitlab.com/group/sub/proj").unwrap();
217        assert_eq!(r.cache_slug(), "gitlab.com/group/sub/proj");
218    }
219}