use std::fmt;
use nils_common::git as common_git;
pub use crate::github::ProviderAdapter;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Provider {
GitHub,
GitLab,
Local,
}
impl Provider {
pub fn as_str(self) -> &'static str {
match self {
Provider::GitHub => "github",
Provider::GitLab => "gitlab",
Provider::Local => "local",
}
}
}
impl fmt::Display for Provider {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Repo {
pub provider: Provider,
pub slug: String,
pub host: Option<String>,
}
impl Repo {
fn default_host(&self) -> &str {
self.host.as_deref().unwrap_or(match self.provider {
Provider::GitHub => "github.com",
Provider::GitLab => "gitlab.com",
Provider::Local => "local",
})
}
pub fn issue_url(&self, issue_number: u64) -> String {
match self.provider {
Provider::GitHub => format!(
"https://{host}/{slug}/issues/{issue_number}",
host = self.default_host(),
slug = self.slug,
),
Provider::GitLab => format!(
"https://{host}/{slug}/-/issues/{issue_number}",
host = self.default_host(),
slug = self.slug,
),
Provider::Local => {
format!("local://{slug}/issues/{issue_number}", slug = self.slug)
}
}
}
pub fn pr_url(&self, pr_number: u64) -> String {
match self.provider {
Provider::GitHub => format!(
"https://{host}/{slug}/pull/{pr_number}",
host = self.default_host(),
slug = self.slug,
),
Provider::GitLab => format!(
"https://{host}/{slug}/-/merge_requests/{pr_number}",
host = self.default_host(),
slug = self.slug,
),
Provider::Local => {
format!("local://{slug}/pull/{pr_number}", slug = self.slug)
}
}
}
}
pub fn resolve_repo(repo_override: Option<&str>) -> Result<Repo, String> {
if let Some(raw) = repo_override {
if let Some(name) = raw.strip_prefix("local:") {
let slug = name.trim().trim_end_matches('/');
if slug.is_empty() {
return Err(format!("invalid --repo value: {raw}"));
}
return Ok(Repo {
provider: Provider::Local,
slug: slug.to_string(),
host: None,
});
}
if let Some(repo) = parse_repo_with_host(raw) {
return Ok(repo);
}
if let Some(slug) = normalize_bare_slug(raw) {
let provider_from_remote = remote_provider().unwrap_or((Provider::GitHub, None));
return Ok(Repo {
provider: provider_from_remote.0,
slug,
host: provider_from_remote.1,
});
}
return Err(format!("invalid --repo value: {raw}"));
}
let (slug, host, provider) = remote_repo()?;
Ok(Repo {
provider,
slug,
host: Some(host),
})
}
pub fn select_adapter(repo: &Repo, force: bool) -> Box<dyn ProviderAdapter> {
match repo.provider {
Provider::GitHub => Box::new(crate::github::GhCliAdapter::new(force)),
Provider::GitLab => Box::new(crate::forge_cli_adapter::ForgeCliAdapter::new(force)),
Provider::Local => Box::new(crate::forge_cli_adapter::ForgeCliAdapter::new_local(force)),
}
}
fn parse_repo_with_host(raw: &str) -> Option<Repo> {
let parsed = common_git::parse_git_remote_url(raw)?;
let provider = classify_host(&parsed.host)?;
if !is_owner_repo(&parsed.path) && !is_group_project_path(&parsed.path) {
return None;
}
Some(Repo {
provider,
slug: parsed.path,
host: Some(parsed.host),
})
}
fn normalize_bare_slug(raw: &str) -> Option<String> {
let trimmed = raw.trim().trim_end_matches('/');
if is_owner_repo(trimmed) || is_group_project_path(trimmed) {
return Some(trimmed.to_string());
}
None
}
fn is_owner_repo(value: &str) -> bool {
let mut parts = value.split('/');
let owner = parts.next().unwrap_or_default().trim();
let repo = parts.next().unwrap_or_default().trim();
parts.next().is_none()
&& !owner.is_empty()
&& !repo.is_empty()
&& !value.contains(':')
&& !value.contains("://")
&& !value.ends_with(".git")
}
fn is_group_project_path(value: &str) -> bool {
if value.contains(':') || value.contains("://") || value.ends_with(".git") {
return false;
}
let segments: Vec<&str> = value.split('/').filter(|s| !s.is_empty()).collect();
segments.len() >= 2 && segments.iter().all(|s| !s.trim().is_empty())
}
fn classify_host(host: &str) -> Option<Provider> {
let host = host.trim().to_ascii_lowercase();
if host == "github.com" || host.ends_with(".github.com") || host.ends_with(".ghe.com") {
Some(Provider::GitHub)
} else if host == "gitlab.com" || host.starts_with("gitlab.") || host.contains(".gitlab.") {
Some(Provider::GitLab)
} else {
None
}
}
fn remote_repo() -> Result<(String, String, Provider), String> {
let output = common_git::run_output(&["remote", "get-url", "origin"])
.map_err(|err| format!("failed to run `git remote get-url origin`: {err}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(format!(
"failed to resolve repository from git remote: {}",
if stderr.is_empty() {
"unknown error"
} else {
&stderr
}
));
}
let remote = String::from_utf8_lossy(&output.stdout).trim().to_string();
parse_remote_url(&remote).ok_or_else(|| {
format!(
"unable to derive owner/repo from origin remote `{remote}`; pass --repo <owner/repo>"
)
})
}
fn remote_provider() -> Option<(Provider, Option<String>)> {
let output = common_git::run_output(&["remote", "get-url", "origin"]).ok()?;
if !output.status.success() {
return None;
}
let remote = String::from_utf8_lossy(&output.stdout).trim().to_string();
parse_remote_url(&remote).map(|(_, host, provider)| (provider, Some(host)))
}
fn parse_remote_url(remote: &str) -> Option<(String, String, Provider)> {
let parsed = common_git::parse_git_remote_url(remote)?;
let provider = classify_host(&parsed.host)?;
if !is_owner_repo(&parsed.path) && !is_group_project_path(&parsed.path) {
return None;
}
Some((parsed.path, parsed.host, provider))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_host_recognises_github_and_gitlab() {
assert_eq!(classify_host("github.com"), Some(Provider::GitHub));
assert_eq!(classify_host("gitlab.com"), Some(Provider::GitLab));
assert_eq!(classify_host("bitbucket.org"), None);
}
#[test]
fn classify_host_is_case_insensitive_and_trims() {
assert_eq!(classify_host("GitHub.com"), Some(Provider::GitHub));
assert_eq!(classify_host(" GitLab.com "), Some(Provider::GitLab));
}
#[test]
fn classify_host_recognises_github_enterprise_hosts() {
assert_eq!(classify_host("internal.ghe.com"), Some(Provider::GitHub));
assert_eq!(classify_host("corp.ghe.com"), Some(Provider::GitHub));
}
#[test]
fn parse_repo_with_host_handles_https_and_ssh_forms() {
let cases = [
(
"https://github.com/sympoies/nils-cli",
Provider::GitHub,
"sympoies/nils-cli",
"github.com",
),
(
"git@gitlab.com:graysury/nils-cli-gitlab-sandbox.git",
Provider::GitLab,
"graysury/nils-cli-gitlab-sandbox",
"gitlab.com",
),
(
"https://gitlab.com/group/sub/project",
Provider::GitLab,
"group/sub/project",
"gitlab.com",
),
];
for (raw, provider, slug, host) in cases {
let repo = parse_repo_with_host(raw).unwrap_or_else(|| panic!("parse {raw}"));
assert_eq!(repo.provider, provider);
assert_eq!(repo.slug, slug);
assert_eq!(repo.host.as_deref(), Some(host));
}
}
#[test]
fn parse_remote_url_handles_common_forms() {
let cases = [
(
"git@github.com:sympoies/nils-cli.git",
"sympoies/nils-cli",
"github.com",
Provider::GitHub,
),
(
"https://gitlab.com/graysury/nils-cli-gitlab-sandbox.git",
"graysury/nils-cli-gitlab-sandbox",
"gitlab.com",
Provider::GitLab,
),
(
"ssh://git@gitlab.com/group/proj.git",
"group/proj",
"gitlab.com",
Provider::GitLab,
),
];
for (remote, slug, host, provider) in cases {
let (s, h, p) = parse_remote_url(remote).unwrap_or_else(|| panic!("parse {remote}"));
assert_eq!(s, slug);
assert_eq!(h, host);
assert_eq!(p, provider);
}
}
#[test]
fn parse_remote_url_strips_basic_auth_userinfo() {
let cases = [
(
"https://user:pass@github.com/sympoies/nils-cli.git",
"sympoies/nils-cli",
"github.com",
Provider::GitHub,
),
(
"https://x-access-token:TOKEN@gitlab.com/group/proj.git",
"group/proj",
"gitlab.com",
Provider::GitLab,
),
];
for (remote, slug, host, provider) in cases {
let (s, h, p) = parse_remote_url(remote).unwrap_or_else(|| panic!("parse {remote}"));
assert_eq!(s, slug);
assert_eq!(h, host);
assert_eq!(p, provider);
}
}
#[test]
fn parse_repo_with_host_strips_basic_auth_userinfo() {
let repo =
parse_repo_with_host("https://user:pass@github.com/sympoies/nils-cli").expect("parse");
assert_eq!(repo.provider, Provider::GitHub);
assert_eq!(repo.slug, "sympoies/nils-cli");
assert_eq!(repo.host.as_deref(), Some("github.com"));
}
#[test]
fn parse_remote_url_strips_userinfo_from_ssh_scheme() {
let (slug, host, provider) =
parse_remote_url("ssh://deploy@gitlab.example.com/group/proj.git").expect("parse");
assert_eq!(slug, "group/proj");
assert_eq!(host, "gitlab.example.com");
assert_eq!(provider, Provider::GitLab);
}
#[test]
fn parse_repo_with_host_strips_userinfo_from_ssh_scheme() {
let repo =
parse_repo_with_host("ssh://deploy@gitlab.example.com/group/proj").expect("parse");
assert_eq!(repo.provider, Provider::GitLab);
assert_eq!(repo.slug, "group/proj");
assert_eq!(repo.host.as_deref(), Some("gitlab.example.com"));
}
#[test]
fn resolve_repo_recognises_local_scheme() {
let repo = resolve_repo(Some("local:demo")).expect("local");
assert_eq!(repo.provider, Provider::Local);
assert_eq!(repo.slug, "demo");
assert_eq!(repo.host, None);
let nested = resolve_repo(Some("local:acme/widgets")).expect("local nested");
assert_eq!(nested.provider, Provider::Local);
assert_eq!(nested.slug, "acme/widgets");
}
#[test]
fn resolve_repo_rejects_empty_local_slug() {
assert!(resolve_repo(Some("local:")).is_err());
assert!(resolve_repo(Some("local:/")).is_err());
}
#[test]
fn local_provider_renders_local_scheme_urls() {
assert_eq!(Provider::Local.as_str(), "local");
let repo = Repo {
provider: Provider::Local,
slug: "demo".into(),
host: None,
};
assert_eq!(repo.issue_url(12), "local://demo/issues/12");
assert_eq!(repo.pr_url(7), "local://demo/pull/7");
}
#[test]
fn is_group_project_path_accepts_nested_groups_only() {
assert!(is_group_project_path("group/project"));
assert!(is_group_project_path("group/sub/project"));
assert!(!is_group_project_path("loose"));
assert!(!is_group_project_path(""));
assert!(!is_group_project_path("with:colon"));
}
}