use anyhow::bail;
use crate::git::Git;
use crate::model::config::GitLabConfig;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitLabProject {
pub scheme: String,
pub host: String,
pub group: String,
pub project: String,
}
impl GitLabProject {
pub fn new(
host: impl Into<String>,
group: impl Into<String>,
project: impl Into<String>,
) -> anyhow::Result<Self> {
let host = host.into();
let group = group.into();
let project = project.into();
Self::validate_host(&host)?;
Self::validate_group_path(&group)?;
Self::validate_identifier(&project, "project")?;
Ok(Self {
scheme: "https".to_string(),
host,
group,
project,
})
}
pub fn with_scheme(mut self, scheme: impl Into<String>) -> Self {
let s = scheme.into();
if s == "https" || s == "http" {
self.scheme = s;
}
self
}
fn validate_identifier(value: &str, field: &str) -> anyhow::Result<()> {
if value.is_empty()
|| value == "."
|| value == ".."
|| !value
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
bail!("Invalid GitLab {field}: {value:?}");
}
Ok(())
}
fn validate_host(value: &str) -> anyhow::Result<()> {
let (hostname, port) = match value.split_once(':') {
Some((h, p)) => (h, Some(p)),
None => (value, None),
};
if port.is_some_and(|p| p.contains(':')) {
bail!("Invalid GitLab host: {value:?}");
}
Self::validate_identifier(hostname, "host")?;
if let Some(p) = port
&& (p.is_empty() || !p.chars().all(|c| c.is_ascii_digit()))
{
bail!("Invalid GitLab host: {value:?}");
}
Ok(())
}
fn validate_group_path(value: &str) -> anyhow::Result<()> {
if value.is_empty() {
bail!("Invalid GitLab group: {value:?}");
}
for segment in value.split('/') {
Self::validate_identifier(segment, "group")?;
}
Ok(())
}
pub(crate) fn parse_url(url: &str) -> Option<Self> {
let url = url.trim();
let (scheme, host, path) = if let Some(rest) = url.strip_prefix("https://") {
let (h, p) = split_host_and_path(rest)?;
("https", h, p)
} else if let Some(rest) = url.strip_prefix("http://") {
let (h, p) = split_host_and_path(rest)?;
("http", h, p)
} else if let Some(rest) = url.strip_prefix("ssh://") {
let rest = rest.split_once('@').map_or(rest, |(_, after)| after);
let (h, p) = split_host_and_path(rest)?;
("https", h, p)
} else {
let rest = url.strip_prefix("git@")?;
let (host, path) = rest.split_once(':')?;
("https", host.to_string(), path.to_string())
};
let path = path.strip_suffix(".git").unwrap_or(&path);
let (group, project) = path.rsplit_once('/')?;
if group.is_empty() {
return None;
}
GitLabProject::new(host, group, project)
.map(|p| p.with_scheme(scheme))
.ok()
}
pub(crate) async fn detect_in(git: &dyn Git) -> anyhow::Result<Option<Self>> {
match git.remote_origin_url().await? {
Some(url) => Ok(Self::parse_url(&url)),
None => Ok(None),
}
}
pub async fn resolve(gitlab_config: &GitLabConfig, git: &dyn Git) -> anyhow::Result<Self> {
match (gitlab_config.group(), gitlab_config.project()) {
(Some(group), Some(project)) => {
let (scheme, host) = scheme_and_host_from_config(&gitlab_config.host);
return GitLabProject::new(host, group, project).map(|p| p.with_scheme(scheme));
}
(Some(_), None) | (None, Some(_)) => bail!(
"[gitlab].group and [gitlab].project must be set together; \
set both or omit both for auto-detection."
),
(None, None) => {}
}
match Self::detect_in(git).await? {
Some(project) => Ok(project),
None => bail!(
"Could not determine GitLab project. Set [gitlab] group and project in config, \
or ensure the git remote 'origin' points to a GitLab project."
),
}
}
}
fn split_host_and_path(s: &str) -> Option<(String, String)> {
let (host_with_port, path) = s.split_once('/')?;
if let Some((_, port)) = host_with_port.split_once(':')
&& (port.is_empty() || !port.chars().all(|c| c.is_ascii_digit()))
{
return None;
}
Some((host_with_port.to_string(), path.to_string()))
}
pub(crate) fn scheme_and_host_from_config(host: &str) -> (String, String) {
let host = host.trim();
if host.is_empty() {
return ("https".to_string(), "gitlab.com".to_string());
}
let (scheme, rest) = if let Some(rest) = host.strip_prefix("https://") {
("https", rest)
} else if let Some(rest) = host.strip_prefix("http://") {
("http", rest)
} else if let Some(idx) = host.find("://") {
("https", &host[idx + 3..])
} else {
("https", host)
};
(scheme.to_string(), rest.trim_end_matches('/').to_string())
}