#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoSplit {
pub owner: String,
pub repo: String,
}
impl RepoSplit {
#[must_use]
pub fn new(owner: &str, repo: &str) -> Self {
Self {
owner: owner.to_string(),
repo: repo.to_string(),
}
}
}
impl std::fmt::Display for RepoSplit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}/{}", self.owner, self.repo)
}
}
pub fn parse_repo_spec(input: &str) -> anyhow::Result<RepoSplit> {
let input = input.trim();
anyhow::ensure!(!input.is_empty(), "repository spec cannot be empty");
let parts: Vec<&str> = input.split('/').collect();
anyhow::ensure!(
parts.len() == 2,
"invalid repository spec '{input}': expected OWNER/REPO format"
);
let owner = parts[0].trim();
let repo = parts[1].trim();
anyhow::ensure!(!owner.is_empty(), "repository owner cannot be empty");
anyhow::ensure!(!repo.is_empty(), "repository name cannot be empty");
let repo = repo.strip_suffix(".git").unwrap_or(repo).to_string();
Ok(RepoSplit {
owner: owner.to_string(),
repo,
})
}
#[must_use]
pub fn detect_remote() -> Option<RepoSplit> {
let repo = gix::discover(std::env::current_dir().ok()?).ok()?;
let remote = repo.find_remote("origin").ok().or_else(|| {
let names: Vec<_> = repo.remote_names().into_iter().collect();
let first_name = names.first()?.clone();
repo.find_remote(first_name.as_ref()).ok()
})?;
let url = remote.url(gix::remote::Direction::Fetch)?;
let host = url.host.as_deref()?;
if host != "github.com" && !host.contains('.') {
return None;
}
let path = std::str::from_utf8(&url.path).ok()?;
let path = path.strip_prefix('/').unwrap_or(path);
let path = path.strip_suffix(".git").unwrap_or(path);
let (owner, repo) = path.split_once('/')?;
if owner.is_empty() || repo.is_empty() {
return None;
}
Some(RepoSplit {
owner: owner.to_string(),
repo: repo.to_string(),
})
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn parse_valid_repo_spec() {
let spec = parse_repo_spec("octocat/hello-world").expect("valid spec");
assert_eq!(spec.owner, "octocat");
assert_eq!(spec.repo, "hello-world");
}
#[test]
fn parse_repo_spec_with_git_suffix() {
let spec = parse_repo_spec("octocat/repo.git").expect("valid spec");
assert_eq!(spec.owner, "octocat");
assert_eq!(spec.repo, "repo");
}
#[test]
fn parse_repo_spec_with_whitespace() {
let spec = parse_repo_spec(" octocat/hello-world ").expect("valid spec");
assert_eq!(spec.owner, "octocat");
assert_eq!(spec.repo, "hello-world");
}
#[test]
fn parse_repo_spec_empty_input() {
let err = parse_repo_spec("").expect_err("should fail on empty input");
assert!(err.to_string().contains("cannot be empty"));
}
#[test]
fn parse_repo_spec_no_slash() {
let err = parse_repo_spec("justarepo").expect_err("should fail without slash");
assert!(err.to_string().contains("OWNER/REPO"));
}
#[test]
fn parse_repo_spec_too_many_slashes() {
let err = parse_repo_spec("a/b/c").expect_err("should fail with too many slashes");
assert!(err.to_string().contains("OWNER/REPO"));
}
#[test]
fn parse_repo_spec_empty_owner() {
let err = parse_repo_spec("/repo").expect_err("should fail with empty owner");
assert!(err.to_string().contains("owner cannot be empty"));
}
#[test]
fn parse_repo_spec_empty_repo() {
let err = parse_repo_spec("owner/").expect_err("should fail with empty repo");
assert!(err.to_string().contains("name cannot be empty"));
}
#[test]
fn reposplit_new_and_to_string() {
let spec = RepoSplit::new("octocat", "hello-world");
assert_eq!(spec.to_string(), "octocat/hello-world");
}
#[test]
fn reposplit_equality() {
let a = RepoSplit::new("foo", "bar");
let b = RepoSplit::new("foo", "bar");
let c = RepoSplit::new("foo", "baz");
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn parse_repo_spec_with_dotgit_in_middle() {
let spec = parse_repo_spec("octocat/my.repo").expect("valid spec");
assert_eq!(spec.repo, "my.repo");
}
#[test]
fn detect_remote_no_git_repo() {
let result = detect_remote();
let _ = result;
}
}