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 derive_github_path(dir: &Path) -> Option<GithubPath> {
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;
}
let url = String::from_utf8_lossy(&output.stdout);
parse_github_path(url.trim())
}
#[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);
}
}