use std::path::Path;
use std::process::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GithubPath {
pub owner: String,
pub repo: String,
}
impl GithubPath {
pub fn rel_path(&self) -> String {
format!("{}/{}", self.owner, self.repo)
}
}
fn slugify_component(input: &str) -> String {
let lowered = input.trim().to_ascii_lowercase();
let stripped = lowered.strip_suffix(".git").unwrap_or(&lowered);
let mut out = String::with_capacity(stripped.len());
let mut prev_hyphen = false;
for c in stripped.chars() {
match c {
'a'..='z' | '0'..='9' => {
out.push(c);
prev_hyphen = false;
}
_ => {
if !prev_hyphen && !out.is_empty() {
out.push('-');
prev_hyphen = true;
}
}
}
}
while out.ends_with('-') {
out.pop();
}
out
}
fn strip_scheme(url: &str) -> &str {
match url.find("://") {
Some(idx) => &url[idx + 3..],
None => url,
}
}
fn host_relative_path(locator: &str) -> &str {
let colon = locator.find(':');
let slash = locator.find('/');
match (colon, slash) {
(Some(c), maybe_slash) if maybe_slash.is_none_or(|s| c < s) => &locator[c + 1..],
(_, Some(s)) => &locator[s + 1..],
_ => locator,
}
}
pub const UNKNOWN_OWNER: &str = "unknown-owner";
pub fn parse_github_path(url: &str) -> Option<GithubPath> {
let trimmed = url.trim();
if trimmed.is_empty() {
return None;
}
let path = host_relative_path(strip_scheme(trimmed));
let path = path.trim_end_matches('/');
let path = path.strip_suffix(".git").unwrap_or(path);
let path = path.trim_end_matches('/');
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
let (owner_raw, repo_raw) = match segments.as_slice() {
[.., owner, repo] => (Some(*owner), *repo),
[repo] => (None, *repo),
_ => return None,
};
let repo = slugify_component(repo_raw);
if repo.is_empty() {
return None;
}
let owner = owner_raw
.map(slugify_component)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| UNKNOWN_OWNER.to_string());
Some(GithubPath { owner, repo })
}
pub fn parse_owner_repo(s: &str) -> Option<GithubPath> {
let trimmed = s.trim();
if trimmed.contains('@') || trimmed.contains("://") {
return None;
}
let segments: Vec<&str> = trimmed.split('/').filter(|p| !p.is_empty()).collect();
let (owner_raw, repo_raw) = match segments.as_slice() {
[.., owner, repo] => (Some(*owner), *repo),
[repo] => (None, *repo),
_ => return None,
};
let repo = slugify_component(repo_raw);
if repo.is_empty() {
return None;
}
let owner = owner_raw
.map(slugify_component)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| UNKNOWN_OWNER.to_string());
Some(GithubPath { owner, repo })
}
pub fn derive_github_path(dir: &Path) -> Option<GithubPath> {
origin_remote_url(dir).and_then(|url| parse_github_path(url.trim()))
}
fn origin_remote_url(dir: &Path) -> Option<String> {
let output = Command::new("git")
.arg("-C")
.arg(dir)
.arg("config")
.arg("--get")
.arg("remote.origin.url")
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct RemoteRepo {
pub host: String,
pub owner: String,
pub repo: String,
}
impl RemoteRepo {
pub fn owner_repo(&self) -> String {
format!("{}/{}", self.owner, self.repo)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum RemoteUrlError {
Malformed {
url: String,
reason: &'static str,
},
NoOrigin {
dir: String,
},
}
impl std::fmt::Display for RemoteUrlError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Malformed { url, reason } => {
write!(f, "`{url}` is not a git remote URL: {reason}")
}
Self::NoOrigin { dir } => {
write!(f, "{dir} is not a git checkout with an `origin` remote")
}
}
}
}
impl std::error::Error for RemoteUrlError {}
pub fn parse_remote_url(url: &str) -> Result<RemoteRepo, RemoteUrlError> {
let trimmed = url.trim();
let malformed = |reason: &'static str| RemoteUrlError::Malformed {
url: trimmed.to_string(),
reason,
};
if trimmed.is_empty() {
return Err(malformed("it is empty"));
}
let (authority, path) = match trimmed.split_once("://") {
Some((_, rest)) => rest
.split_once('/')
.ok_or_else(|| malformed("the URL has no path after the host"))?,
None => trimmed
.split_once(':')
.ok_or_else(|| malformed("expected `scheme://host/owner/repo` or `host:owner/repo`"))?,
};
let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
if host.is_empty() {
return Err(malformed("the host is empty"));
}
let path = path.trim_matches('/');
let path = path.strip_suffix(".git").unwrap_or(path);
let path = path.trim_end_matches('/');
let segments: Vec<&str> = path.split('/').collect();
let [owner, repo] = segments.as_slice() else {
return Err(malformed("the path is not exactly `<owner>/<repo>`"));
};
if owner.is_empty() || repo.is_empty() {
return Err(malformed("the owner or the repository name is empty"));
}
Ok(RemoteRepo {
host: host.to_string(),
owner: (*owner).to_string(),
repo: (*repo).to_string(),
})
}
pub fn derive_remote_repo(dir: &Path) -> Result<RemoteRepo, RemoteUrlError> {
let url = origin_remote_url(dir).ok_or_else(|| RemoteUrlError::NoOrigin {
dir: dir.display().to_string(),
})?;
parse_remote_url(&url)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_ssh_github() {
let gp = parse_github_path("git@github.com:bobmatnyc/trusty-tools.git").expect("parsed");
assert_eq!(gp.owner, "bobmatnyc");
assert_eq!(gp.repo, "trusty-tools");
}
#[test]
fn parse_https_github_with_and_without_dot_git() {
let with = parse_github_path("https://github.com/bobmatnyc/trusty-tools.git").unwrap();
let without = parse_github_path("https://github.com/bobmatnyc/trusty-tools").unwrap();
assert_eq!(with, without);
assert_eq!(with.owner, "bobmatnyc");
assert_eq!(with.repo, "trusty-tools");
}
#[test]
fn parse_non_github_host() {
let gp = parse_github_path("git@gitlab.example.com:Acme/Cool_App.git").unwrap();
assert_eq!(gp.owner, "acme");
assert_eq!(gp.repo, "cool-app");
}
#[test]
fn parse_trailing_slash() {
let gp = parse_github_path("https://github.com/bobmatnyc/trusty-tools/").unwrap();
assert_eq!(gp.owner, "bobmatnyc");
assert_eq!(gp.repo, "trusty-tools");
}
#[test]
fn parse_nested_group_takes_last_two() {
let gp = parse_github_path("https://gitlab.com/acme/team/widget.git").unwrap();
assert_eq!(gp.owner, "team");
assert_eq!(gp.repo, "widget");
}
#[test]
fn parse_repo_only_uses_unknown_owner() {
let gp = parse_github_path("git@host:repo.git").unwrap();
assert_eq!(gp.owner, UNKNOWN_OWNER);
assert_eq!(gp.repo, "repo");
}
#[test]
fn parse_empty_returns_none() {
assert_eq!(parse_github_path(""), None);
assert_eq!(parse_github_path(" "), None);
assert_eq!(parse_github_path("https://github.com/"), None);
}
#[test]
fn github_path_rel_joins_owner_repo() {
let gp = GithubPath {
owner: "bobmatnyc".into(),
repo: "trusty-tools".into(),
};
assert_eq!(gp.rel_path(), "bobmatnyc/trusty-tools");
}
#[test]
fn derive_github_path_reads_origin() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let dir = tmp.path();
let git = |args: &[&str]| {
Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
};
if !git(&["init"]) {
return;
}
let _ = git(&[
"remote",
"add",
"origin",
"git@github.com:bobmatnyc/trusty-tools.git",
]);
let gp = derive_github_path(dir).expect("derived from origin");
assert_eq!(gp.owner, "bobmatnyc");
assert_eq!(gp.repo, "trusty-tools");
}
#[test]
fn derive_github_path_none_outside_repo() {
let tmp = tempfile::TempDir::new().expect("tempdir");
assert_eq!(derive_github_path(tmp.path()), None);
}
#[test]
fn parse_owner_repo_roundtrips_rel_path() {
let gp = parse_owner_repo("bobmatnyc/trusty-tools").expect("parsed");
assert_eq!(gp.owner, "bobmatnyc");
assert_eq!(gp.repo, "trusty-tools");
assert_eq!(parse_owner_repo(&gp.rel_path()), Some(gp));
let nested = parse_owner_repo("acme/team/widget").unwrap();
assert_eq!(nested.owner, "team");
assert_eq!(nested.repo, "widget");
let slug = parse_owner_repo("Acme/Cool_App").unwrap();
assert_eq!(slug.owner, "acme");
assert_eq!(slug.repo, "cool-app");
}
#[test]
fn parse_owner_repo_single_segment() {
let gp = parse_owner_repo("repo").unwrap();
assert_eq!(gp.owner, UNKNOWN_OWNER);
assert_eq!(gp.repo, "repo");
}
#[test]
fn parse_owner_repo_empty_returns_none() {
assert_eq!(parse_owner_repo(""), None);
assert_eq!(parse_owner_repo(" "), None);
assert_eq!(parse_owner_repo("///"), None);
}
#[test]
fn parse_remote_url_table() {
for (url, host, owner, repo) in [
(
"https://github.com/bobmatnyc/Trusty-Tools.git",
"github.com",
"bobmatnyc",
"Trusty-Tools",
),
(
"https://github.com/bobmatnyc/trusty-tools",
"github.com",
"bobmatnyc",
"trusty-tools",
),
(
"https://user@github.com/acme/widget",
"github.com",
"acme",
"widget",
),
(
"git@github.com:duettoresearch/pricing.git",
"github.com",
"duettoresearch",
"pricing",
),
(
"ssh://git@github.com/acme/widget.git",
"github.com",
"acme",
"widget",
),
(
"https://ghe.corp.example.com/Platform/Deploy_Tools.git",
"ghe.corp.example.com",
"Platform",
"Deploy_Tools",
),
(
"git@ghe.corp.example.com:Platform/deploy.git",
"ghe.corp.example.com",
"Platform",
"deploy",
),
(
"https://git.example.com:8443/owner/repo",
"git.example.com:8443",
"owner",
"repo",
),
(
"https://github.com/acme/widget/",
"github.com",
"acme",
"widget",
),
] {
let parsed = parse_remote_url(url).unwrap_or_else(|e| panic!("`{url}`: {e}"));
assert_eq!(parsed.host, host, "host of `{url}`");
assert_eq!(parsed.owner, owner, "owner of `{url}`");
assert_eq!(parsed.repo, repo, "repo of `{url}`");
assert_eq!(parsed.owner_repo(), format!("{owner}/{repo}"));
}
for garbage in [
"",
" ",
"nonsense",
"/var/log/trusty-mpm",
"https://github.com/",
"https://github.com/owner",
"https://gitlab.com/group/subgroup/repo",
"git@github.com:",
] {
let err =
parse_remote_url(garbage).expect_err("garbage must be refused, never guessed at");
assert!(
matches!(err, RemoteUrlError::Malformed { .. }),
"`{garbage}` produced {err:?}"
);
}
}
#[test]
fn derive_remote_repo_reads_origin() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let dir = tmp.path();
let git = |args: &[&str]| {
Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
};
if !git(&["init"]) {
return;
}
let _ = git(&[
"remote",
"add",
"origin",
"git@github.com:duettoresearch/Pricing.git",
]);
let parsed = derive_remote_repo(dir).expect("derived from origin");
assert_eq!(parsed.owner, "duettoresearch");
assert_eq!(parsed.repo, "Pricing", "the case the remote spells");
let nested = dir.join("logs");
std::fs::create_dir_all(&nested).expect("nested dir");
assert_eq!(
derive_remote_repo(&nested).expect("git walks up to the checkout"),
parsed
);
}
#[test]
fn derive_remote_repo_without_origin() {
let tmp = tempfile::TempDir::new().expect("tempdir");
match derive_remote_repo(tmp.path()) {
Err(RemoteUrlError::NoOrigin { dir }) => {
assert!(dir.contains(&*tmp.path().to_string_lossy()), "{dir}");
}
other => panic!("expected NoOrigin, got {other:?}"),
}
}
#[test]
fn parse_owner_repo_rejects_url_shaped_input() {
assert_eq!(
parse_owner_repo("git@github.com:bobmatnyc/trusty-tools.git"),
None
);
assert_eq!(
parse_owner_repo("https://github.com/bobmatnyc/trusty-tools"),
None
);
assert_eq!(parse_owner_repo("ssh://git@host/owner/repo"), None);
}
}