use anyhow::{Context, Result, bail};
use vgi_core::{ResourceErrorKind, normalize_resource};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
pub enum ResourceFormat {
#[default]
Legacy,
Qualified,
}
#[derive(Debug, Clone, Default)]
pub struct CiEnv {
pub forgejo_server_url: Option<String>,
pub forgejo_repository: Option<String>,
pub github_server_url: Option<String>,
pub github_repository: Option<String>,
}
impl CiEnv {
pub fn from_env() -> Self {
Self::from_lookup(|name| std::env::var(name).ok())
}
pub fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Self {
Self {
forgejo_server_url: lookup("FORGEJO_SERVER_URL"),
forgejo_repository: lookup("FORGEJO_REPOSITORY"),
github_server_url: lookup("GITHUB_SERVER_URL"),
github_repository: lookup("GITHUB_REPOSITORY"),
}
}
fn detected(&self) -> Option<(&'static str, &str, &str)> {
fn pair<'a>(
url: &'a Option<String>,
repo: &'a Option<String>,
) -> Option<(&'a str, &'a str)> {
let url = url.as_deref().filter(|v| !v.is_empty())?;
let repo = repo.as_deref().filter(|v| !v.is_empty())?;
Some((url, repo))
}
pair(&self.forgejo_server_url, &self.forgejo_repository)
.map(|(url, repo)| ("FORGEJO_SERVER_URL + FORGEJO_REPOSITORY", url, repo))
.or_else(|| {
pair(&self.github_server_url, &self.github_repository)
.map(|(url, repo)| ("GITHUB_SERVER_URL + GITHUB_REPOSITORY", url, repo))
})
}
pub fn forge_host(&self) -> Option<String> {
[&self.forgejo_server_url, &self.github_server_url]
.into_iter()
.flatten()
.find_map(|url| forge_host_of(url))
}
pub fn qualified_resource(&self) -> Result<Option<String>> {
let Some((source, url, repo)) = self.detected() else {
return Ok(None);
};
let host = forge_host_of(url)
.with_context(|| format!("cannot read a forge host from {source} (`{url}`)"))?;
normalize_qualified(
&format!("resource derived from {source}"),
&format!("{host}/{repo}"),
self,
)
.map(Some)
}
}
pub fn forge_host_of(url: &str) -> Option<String> {
let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
let host_port = authority
.rsplit_once('@')
.map_or(authority, |(_, host)| host);
let host = host_port.split(':').next().unwrap_or_default();
(!host.is_empty()).then(|| host.to_ascii_lowercase())
}
pub fn normalize_qualified(what: &str, value: &str, ci: &CiEnv) -> Result<String> {
match normalize_resource(value) {
Ok(canonical) => Ok(canonical),
Err(e) if *e.kind() == ResourceErrorKind::MissingForgeHost => {
let bare = value.trim_matches('/').to_ascii_lowercase();
let fix = match ci.forge_host() {
Some(detected) => format!("did you mean `{detected}/{bare}`?"),
None => format!("prefix the forge host, e.g. `github.com/{bare}`"),
};
bail!(
"{what} `{value}` is not forge-qualified (--resource-format qualified expects \
`<forge-host>/<owner>[/<repo>]`); {fix}"
)
}
Err(e) => bail!("{}", e.describe(what)),
}
}
pub fn select_resources(
format: ResourceFormat,
resource: Option<String>,
fallback_resource: Option<String>,
ci: &CiEnv,
) -> Result<(String, Option<String>)> {
match format {
ResourceFormat::Legacy => {
let resource = resource
.or_else(|| ci.github_repository.clone())
.context("--resource is required (or set GITHUB_REPOSITORY)")?;
Ok((resource, fallback_resource))
}
ResourceFormat::Qualified => {
let resource = match resource {
Some(value) => normalize_qualified("--resource", &value, ci)?,
None => ci.qualified_resource()?.context(
"--resource is required: no CI environment detected to derive it from \
(FORGEJO_SERVER_URL + FORGEJO_REPOSITORY, or GITHUB_SERVER_URL + \
GITHUB_REPOSITORY); pass e.g. `--resource github.com/acme/widgets`",
)?,
};
let fallback = fallback_resource
.map(|value| normalize_qualified("--fallback-resource", &value, ci))
.transpose()?;
Ok((resource, fallback))
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
fn ci(vars: &[(&str, &str)]) -> CiEnv {
CiEnv::from_lookup(|name| {
vars.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| (*v).to_string())
})
}
fn github() -> CiEnv {
ci(&[
("GITHUB_SERVER_URL", "https://github.com"),
("GITHUB_REPOSITORY", "Acme/Widgets"),
])
}
fn err(what: &str, value: &str, ci: &CiEnv) -> String {
normalize_qualified(what, value, ci)
.unwrap_err()
.to_string()
}
#[test]
fn github_actions_yields_the_lowercased_github_resource() {
assert_eq!(
github().qualified_resource().unwrap().as_deref(),
Some("github.com/acme/widgets")
);
}
#[test]
fn an_enterprise_server_gets_its_own_host() {
let env = ci(&[
("GITHUB_SERVER_URL", "https://GHE.Example.com/"),
("GITHUB_REPOSITORY", "acme/widgets"),
]);
assert_eq!(
env.qualified_resource().unwrap().as_deref(),
Some("ghe.example.com/acme/widgets")
);
}
#[test]
fn forgejo_names_take_precedence_over_the_github_ones() {
let env = ci(&[
("FORGEJO_SERVER_URL", "https://codeberg.org"),
("FORGEJO_REPOSITORY", "acme/widgets"),
("GITHUB_SERVER_URL", "https://github.com"),
("GITHUB_REPOSITORY", "other/thing"),
]);
assert_eq!(
env.qualified_resource().unwrap().as_deref(),
Some("codeberg.org/acme/widgets")
);
}
#[test]
fn a_half_set_forgejo_pair_falls_back_to_github_names() {
let env = ci(&[
("FORGEJO_SERVER_URL", "https://git.example.org"),
("GITHUB_SERVER_URL", "https://git.example.org"),
("GITHUB_REPOSITORY", "acme/widgets"),
]);
assert_eq!(
env.qualified_resource().unwrap().as_deref(),
Some("git.example.org/acme/widgets")
);
}
#[test]
fn a_port_is_not_part_of_the_forge_host() {
let env = ci(&[
(
"FORGEJO_SERVER_URL",
"http://user@git.example.org:3000/forgejo",
),
("FORGEJO_REPOSITORY", "acme/widgets"),
]);
assert_eq!(
env.qualified_resource().unwrap().as_deref(),
Some("git.example.org/acme/widgets")
);
assert_eq!(
forge_host_of("http://localhost:3000").as_deref(),
Some("localhost")
);
}
#[test]
fn no_ci_environment_derives_nothing() {
assert_eq!(CiEnv::default().qualified_resource().unwrap(), None);
let env = ci(&[("GITHUB_REPOSITORY", "acme/widgets")]);
assert_eq!(env.qualified_resource().unwrap(), None);
let env = ci(&[
("GITHUB_SERVER_URL", ""),
("GITHUB_REPOSITORY", "acme/widgets"),
]);
assert_eq!(env.qualified_resource().unwrap(), None);
}
#[test]
fn qualified_values_are_accepted_and_lowercased() {
let none = CiEnv::default();
for (value, expected) in [
("GitHub.com/Acme/Widgets", "github.com/acme/widgets"),
("github.com/acme", "github.com/acme"),
("codeberg.org/acme/widgets", "codeberg.org/acme/widgets"),
("localhost/acme/widgets", "localhost/acme/widgets"),
(
"gitlab.example.org/group/sub/project",
"gitlab.example.org/group/sub/project",
),
] {
assert_eq!(
normalize_qualified("--resource", value, &none).unwrap(),
expected
);
}
}
#[test]
fn an_unqualified_value_suggests_the_detected_forge() {
let message = err("--resource", "Acme/Widgets", &github());
assert!(message.contains("not forge-qualified"), "{message}");
assert!(
message.contains("did you mean `github.com/acme/widgets`?"),
"{message}"
);
let message = err("--fallback-resource", "acme", &github());
assert!(
message.starts_with("--fallback-resource `acme`"),
"{message}"
);
assert!(
message.contains("did you mean `github.com/acme`?"),
"{message}"
);
}
#[test]
fn an_unqualified_value_outside_ci_suggests_a_prefix() {
let message = err("--resource", "acme/widgets", &CiEnv::default());
assert!(
message.contains("prefix the forge host, e.g. `github.com/acme/widgets`"),
"{message}"
);
}
#[test]
fn malformed_values_are_rejected_with_a_fix() {
let none = CiEnv::default();
let cases = [
("", "is empty"),
("github.com/acme widgets", "whitespace"),
(
"https://github.com/acme/widgets.git",
"did you mean `github.com/acme/widgets`?",
),
(
"/github.com/acme/widgets",
"did you mean `github.com/acme/widgets`?",
),
(
"github.com/acme/widgets/",
"did you mean `github.com/acme/widgets`?",
),
("github.com//widgets", "empty path segment"),
("github.com/acme/../other", "`.` or `..` segment"),
("github.com/./acme", "`.` or `..` segment"),
(
"git.example.org:3000/acme",
"did you mean `git.example.org/acme`?",
),
("github.com", "names a forge but no owner"),
("github..com/acme", "invalid forge host"),
("github.com/acme@x", "may only contain"),
];
for (value, expected) in cases {
let message = err("--resource", value, &none);
assert!(message.contains(expected), "{value:?}: {message}");
}
}
#[test]
fn legacy_is_the_default_and_passes_values_through_untouched() {
assert_eq!(ResourceFormat::default(), ResourceFormat::Legacy);
assert_eq!(
select_resources(ResourceFormat::Legacy, None, None, &github()).unwrap(),
("Acme/Widgets".to_string(), None)
);
assert_eq!(
select_resources(
ResourceFormat::Legacy,
Some("Acme/Widgets".into()),
Some("Acme".into()),
&github()
)
.unwrap(),
("Acme/Widgets".to_string(), Some("Acme".to_string()))
);
let env = ci(&[
("FORGEJO_SERVER_URL", "https://codeberg.org"),
("FORGEJO_REPOSITORY", "acme/widgets"),
]);
let message = select_resources(ResourceFormat::Legacy, None, None, &env)
.unwrap_err()
.to_string();
assert_eq!(message, "--resource is required (or set GITHUB_REPOSITORY)");
}
#[test]
fn qualified_mode_derives_and_validates_both_resources() {
assert_eq!(
select_resources(
ResourceFormat::Qualified,
None,
Some("GitHub.com/Acme".into()),
&github()
)
.unwrap(),
(
"github.com/acme/widgets".to_string(),
Some("github.com/acme".to_string())
)
);
let message = select_resources(
ResourceFormat::Qualified,
None,
Some("acme".into()),
&github(),
)
.unwrap_err()
.to_string();
assert!(
message.contains("did you mean `github.com/acme`?"),
"{message}"
);
}
#[test]
fn qualified_mode_outside_ci_requires_an_explicit_resource() {
let message = select_resources(ResourceFormat::Qualified, None, None, &CiEnv::default())
.unwrap_err()
.to_string();
assert!(message.contains("--resource is required"), "{message}");
assert!(message.contains("github.com/acme/widgets"), "{message}");
}
}