#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RepoRef {
pub host: String,
pub owner: String,
pub repo: String,
pub clone_url: String,
pub git_ref: Option<String>,
pub subpath: Option<String>,
}
impl RepoRef {
pub(crate) fn project_path(&self) -> String {
format!("{}/{}", self.owner, self.repo)
}
pub(crate) fn cache_slug(&self) -> String {
format!("{}/{}/{}", self.host, self.owner, self.repo)
}
}
pub(crate) fn parse(url: &str) -> Option<RepoRef> {
let rest = url.trim().strip_prefix("https://")?;
let (host, path) = rest.split_once('/')?;
if !is_valid_host(host) {
return None;
}
let path = path.split(['?', '#']).next().unwrap_or("");
let path = path.trim_matches('/');
if path.is_empty() {
return None;
}
let (project_path, git_ref, subpath) = split_project_and_location(path);
let project_path = project_path.trim_end_matches('/').trim_end_matches(".git");
let segments: Vec<&str> = project_path.split('/').filter(|s| !s.is_empty()).collect();
if segments.len() < 2 {
return None; }
let repo = (*segments.last()?).to_string();
let owner = segments[..segments.len() - 1].join("/");
if owner.is_empty() || repo.is_empty() {
return None;
}
let clone_url = format!("https://{host}/{owner}/{repo}.git");
Some(RepoRef {
host: host.to_string(),
owner,
repo,
clone_url,
git_ref,
subpath,
})
}
fn split_project_and_location(path: &str) -> (String, Option<String>, Option<String>) {
if let Some((proj, tail)) = path.split_once("/-/") {
let (git_ref, subpath) = parse_location_tail(tail);
return (proj.to_string(), git_ref, subpath);
}
let segs: Vec<&str> = path.split('/').collect();
if segs.len() >= 4 && matches!(segs[2], "blob" | "tree" | "raw" | "src" | "commits") {
let project = segs[..2].join("/");
let tail = segs[3..].join("/");
let (git_ref, subpath) = parse_ref_then_path(&tail);
return (project, git_ref, subpath);
}
(path.to_string(), None, None)
}
fn parse_location_tail(tail: &str) -> (Option<String>, Option<String>) {
let segs: Vec<&str> = tail.split('/').filter(|s| !s.is_empty()).collect();
if segs.is_empty() {
return (None, None);
}
let after_kind = if matches!(segs[0], "blob" | "tree" | "raw") {
&segs[1..]
} else {
&segs[..]
};
parse_ref_then_path(&after_kind.join("/"))
}
fn parse_ref_then_path(s: &str) -> (Option<String>, Option<String>) {
let s = s.trim_matches('/');
if s.is_empty() {
return (None, None);
}
match s.split_once('/') {
Some((r, p)) if !p.is_empty() => (Some(r.to_string()), Some(p.to_string())),
_ => (Some(s.to_string()), None),
}
}
fn is_valid_host(host: &str) -> bool {
!host.is_empty()
&& host.contains('.')
&& !host.contains(' ')
&& !host.contains('@')
&& !host.starts_with('.')
&& !host.ends_with('.')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_bare_repo_root() {
let r = parse("https://github.com/yvgude/lean-ctx").unwrap();
assert_eq!(r.host, "github.com");
assert_eq!(r.owner, "yvgude");
assert_eq!(r.repo, "lean-ctx");
assert_eq!(r.clone_url, "https://github.com/yvgude/lean-ctx.git");
assert_eq!(r.git_ref, None);
assert_eq!(r.subpath, None);
}
#[test]
fn strips_dot_git_and_trailing_slash() {
let r = parse("https://github.com/o/r.git/").unwrap();
assert_eq!(r.repo, "r");
assert_eq!(r.clone_url, "https://github.com/o/r.git");
}
#[test]
fn parses_github_blob_ref_and_subpath() {
let r = parse("https://github.com/yvgude/lean-ctx/blob/main/src/core/mod.rs").unwrap();
assert_eq!(r.project_path(), "yvgude/lean-ctx");
assert_eq!(r.git_ref.as_deref(), Some("main"));
assert_eq!(r.subpath.as_deref(), Some("src/core/mod.rs"));
assert_eq!(r.clone_url, "https://github.com/yvgude/lean-ctx.git");
}
#[test]
fn parses_github_tree_ref_only() {
let r = parse("https://github.com/o/r/tree/v1.2.3").unwrap();
assert_eq!(r.git_ref.as_deref(), Some("v1.2.3"));
assert_eq!(r.subpath, None);
}
#[test]
fn parses_gitlab_dash_blob_with_nested_group() {
let r = parse("https://gitlab.com/group/sub/proj/-/blob/main/a/b.rs").unwrap();
assert_eq!(r.host, "gitlab.com");
assert_eq!(r.owner, "group/sub");
assert_eq!(r.repo, "proj");
assert_eq!(r.git_ref.as_deref(), Some("main"));
assert_eq!(r.subpath.as_deref(), Some("a/b.rs"));
assert_eq!(r.clone_url, "https://gitlab.com/group/sub/proj.git");
}
#[test]
fn parses_gitlab_tree_dir() {
let r = parse("https://gitlab.com/o/r/-/tree/dev/src").unwrap();
assert_eq!(r.git_ref.as_deref(), Some("dev"));
assert_eq!(r.subpath.as_deref(), Some("src"));
}
#[test]
fn drops_query_and_fragment() {
let r = parse("https://github.com/o/r/blob/main/x.rs?plain=1#L10").unwrap();
assert_eq!(r.subpath.as_deref(), Some("x.rs"));
}
#[test]
fn rejects_non_https_and_garbage() {
assert!(parse("http://github.com/o/r").is_none());
assert!(parse("git@github.com:o/r.git").is_none());
assert!(parse("https://github.com/justowner").is_none());
assert!(parse("https://localhost/o/r").is_none()); assert!(parse("not a url").is_none());
}
#[test]
fn cache_slug_is_filesystem_nested() {
let r = parse("https://gitlab.com/group/sub/proj").unwrap();
assert_eq!(r.cache_slug(), "gitlab.com/group/sub/proj");
}
}