use anyhow::{Context, Result, bail};
use crate::config::Protocol;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteParts {
pub host: String,
pub owner: String,
pub name: String,
}
pub fn parse_remote_url(url: &gix::Url) -> Result<RemoteParts> {
let host = url.host().context("remote URL has no host")?.to_string();
let trimmed = std::str::from_utf8(url.path.as_ref())
.context("remote URL path is not UTF-8")?
.trim_matches('/');
let path = trimmed.strip_suffix(".git").unwrap_or(trimmed);
let mut iter = path.splitn(2, '/');
let owner = iter
.next()
.filter(|s| !s.is_empty())
.context("remote URL has no owner segment")?
.to_string();
let name = iter
.next()
.filter(|s| !s.is_empty())
.context("remote URL has no name segment")?
.to_string();
if name.contains('/') {
bail!("remote URL path `{path}` has more than two segments");
}
Ok(RemoteParts { host, owner, name })
}
pub fn parse_clone_input(
input: &str,
default_host: &str,
default_protocol: Protocol,
) -> Result<(RemoteParts, gix::Url)> {
let trimmed = input.trim();
if trimmed.is_empty() {
bail!("empty clone input — pass a URL or `owner/name`");
}
let url_string = if looks_like_url(trimmed) {
trimmed.to_string()
} else {
let segs: Vec<&str> = trimmed.split('/').collect();
match segs.as_slice() {
[owner, name] if !owner.is_empty() && !name.is_empty() => {
build_url(default_host, owner, name, default_protocol)
}
[host, owner, name] if !host.is_empty() && !owner.is_empty() && !name.is_empty() => {
build_url(host, owner, name, default_protocol)
}
_ => bail!(
"input `{trimmed}` is neither a URL nor `<owner>/<name>` / \
`<host>/<owner>/<name>` shape; pass a full URL or use the \
ghq-style shorthand"
),
}
};
let url = gix::url::parse(url_string.as_str().into())
.with_context(|| format!("parsing clone URL `{url_string}`"))?;
let parts = parse_remote_url(&url)
.with_context(|| format!("extracting host/owner/name from `{url_string}`"))?;
Ok((parts, url))
}
fn build_url(host: &str, owner: &str, name: &str, protocol: Protocol) -> String {
match protocol {
Protocol::Https => format!("https://{host}/{owner}/{name}.git"),
Protocol::Ssh => format!("git@{host}:{owner}/{name}.git"),
}
}
fn looks_like_url(s: &str) -> bool {
s.contains(':')
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_url(url_str: &str) -> Result<RemoteParts> {
let url = gix::url::parse(url_str.into())
.with_context(|| format!("parsing test URL `{url_str}`"))?;
parse_remote_url(&url)
}
#[test]
fn ssh_url() {
let p = parse_url("git@github.com:foo/bar.git").unwrap();
assert_eq!(p.host, "github.com");
assert_eq!(p.owner, "foo");
assert_eq!(p.name, "bar");
}
#[test]
fn https_url() {
let p = parse_url("https://github.com/foo/bar.git").unwrap();
assert_eq!(p.host, "github.com");
assert_eq!(p.owner, "foo");
assert_eq!(p.name, "bar");
}
#[test]
fn https_url_without_dot_git() {
let p = parse_url("https://github.com/foo/bar").unwrap();
assert_eq!(p.owner, "foo");
assert_eq!(p.name, "bar");
}
#[test]
fn ssh_url_without_dot_git() {
let p = parse_url("git@github.com:foo/bar").unwrap();
assert_eq!(p.owner, "foo");
assert_eq!(p.name, "bar");
}
#[test]
fn url_with_trailing_slash() {
let p = parse_url("https://github.com/foo/bar/").unwrap();
assert_eq!(p.owner, "foo");
assert_eq!(p.name, "bar");
}
#[test]
fn trailing_slash_after_dot_git_strips_both() {
let p = parse_url("https://github.com/foo/bar.git/").unwrap();
assert_eq!(p.name, "bar");
}
#[test]
fn ssh_alternate_user_segment_drops_user() {
let p = parse_url("ssh://git@gh.example.com/foo/bar.git").unwrap();
assert_eq!(p.host, "gh.example.com");
assert_eq!(p.owner, "foo");
assert_eq!(p.name, "bar");
}
#[test]
fn subgroup_rejected_as_too_deep() {
let err = parse_url("https://gitlab.com/group/sub/repo.git").unwrap_err();
assert!(
err.to_string().contains("more than two segments"),
"expected too-deep error, got: {err}"
);
}
#[test]
fn clone_input_full_https_url_passes_through() {
let (parts, url) = parse_clone_input(
"https://github.com/foo/bar.git",
"github.com",
Protocol::Https,
)
.unwrap();
assert_eq!(parts.host, "github.com");
assert_eq!(parts.owner, "foo");
assert_eq!(parts.name, "bar");
assert_eq!(url.to_string(), "https://github.com/foo/bar.git");
}
#[test]
fn clone_input_userless_scp_ssh_passes_through() {
let (parts, _) =
parse_clone_input("github.com:foo/bar.git", "example.com", Protocol::Https).unwrap();
assert_eq!(parts.host, "github.com");
assert_eq!(parts.owner, "foo");
assert_eq!(parts.name, "bar");
}
#[test]
fn clone_input_ssh_shorthand_passes_through() {
let (parts, _) =
parse_clone_input("git@github.com:foo/bar.git", "github.com", Protocol::Https).unwrap();
assert_eq!(parts.host, "github.com");
assert_eq!(parts.owner, "foo");
assert_eq!(parts.name, "bar");
}
#[test]
fn clone_input_owner_name_uses_default_host_https() {
let (parts, url) = parse_clone_input("foo/bar", "github.com", Protocol::Https).unwrap();
assert_eq!(parts.host, "github.com");
assert_eq!(parts.owner, "foo");
assert_eq!(parts.name, "bar");
assert_eq!(url.to_string(), "https://github.com/foo/bar.git");
}
#[test]
fn clone_input_owner_name_uses_default_host_ssh() {
let (parts, _) = parse_clone_input("foo/bar", "github.com", Protocol::Ssh).unwrap();
assert_eq!(parts.host, "github.com");
assert_eq!(parts.owner, "foo");
assert_eq!(parts.name, "bar");
}
#[test]
fn clone_input_host_owner_name_overrides_default_host() {
let (parts, _) =
parse_clone_input("gitlab.com/group/proj", "github.com", Protocol::Https).unwrap();
assert_eq!(parts.host, "gitlab.com");
assert_eq!(parts.owner, "group");
assert_eq!(parts.name, "proj");
}
#[test]
fn clone_input_empty_errors() {
let err = parse_clone_input(" ", "github.com", Protocol::Https).unwrap_err();
assert!(
err.to_string().contains("empty"),
"expected empty-input error, got: {err}"
);
}
#[test]
fn clone_input_single_token_errors() {
let err = parse_clone_input("justaname", "github.com", Protocol::Https).unwrap_err();
assert!(
err.to_string().contains("neither a URL"),
"expected shape error, got: {err}"
);
}
#[test]
fn clone_input_four_segments_errors() {
let err = parse_clone_input("a/b/c/d", "github.com", Protocol::Https).unwrap_err();
assert!(
err.to_string().contains("neither a URL"),
"expected shape error, got: {err}"
);
}
}