use super::provider::IdForm;
use super::{ExternalIdentity, ExternalObjectKind, ExternalProvider};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ObjectKey {
pub family: ExternalProvider,
pub instance: String,
pub namespace: Option<String>,
pub space: Option<String>,
pub id: String,
}
impl ExternalIdentity {
#[must_use]
pub fn canonical(&self) -> Self {
let provider = match &self.provider {
ExternalProvider::Other(name) => ExternalProvider::parse(name),
known => known.clone(),
};
let namespace = self
.namespace
.as_deref()
.map(|namespace| canonical_namespace(&provider, namespace))
.filter(|namespace| !namespace.is_empty());
let rules = provider.rules();
Self {
instance: canonical_instance_for(&provider, &self.instance),
namespace,
kind: rules.canonical_kind(&self.kind),
external_id: canonical_external_id(rules.ids, &self.external_id),
provider,
}
}
#[must_use]
pub fn object_key(&self) -> ObjectKey {
let canonical = self.canonical();
ObjectKey {
family: canonical.provider.family(),
space: canonical.provider.rules().space(&canonical.kind),
instance: canonical.instance,
namespace: canonical.namespace,
id: canonical.external_id,
}
}
#[must_use]
pub fn permits_kind_change_to(&self, next: &Self) -> bool {
let (from, to) = (self.canonical(), next.canonical());
if from.kind == to.kind {
return true;
}
let space = |identity: &Self| identity.provider.rules().space(&identity.kind);
from.kind != ExternalObjectKind::PullRequest
&& from.provider.family() == to.provider.family()
&& space(&from).is_some()
&& space(&from) == space(&to)
}
#[must_use]
pub fn refines_kind_of(&self, recorded: &Self) -> bool {
self.canonical().kind != recorded.canonical().kind
&& recorded.permits_kind_change_to(self)
&& self.object_key() == recorded.object_key()
}
}
fn to_fixed_point(text: String, step: impl Fn(&str) -> String) -> String {
let mut current = text;
loop {
let next = step(¤t);
if next == current {
return current;
}
current = next;
}
}
fn canonical_instance(instance: &str) -> String {
to_fixed_point(instance.to_lowercase(), |text| {
let text = text.trim();
let text = text
.strip_prefix("https://")
.or_else(|| text.strip_prefix("http://"))
.unwrap_or(text)
.trim_end_matches('/');
let (host, port) = match text.rsplit_once(':') {
Some((host, port)) if port.chars().all(|c| c.is_ascii_digit()) => (host, Some(port)),
_ => (text, None),
};
let host = host.strip_suffix('.').unwrap_or(host).trim_end_matches('/');
match port.map(|port| (port, port.trim_start_matches('0'))) {
None | Some(("", _)) | Some((_, "443" | "80")) => host.to_owned(),
Some((_, "")) => format!("{host}:0"),
Some((_, port)) => format!("{host}:{port}"),
}
})
}
pub(super) fn canonical_instance_for(provider: &ExternalProvider, instance: &str) -> String {
let instance = canonical_instance(instance);
match (provider.rules().www_host, instance.strip_prefix("www.")) {
(Some(host), Some(bare)) if bare == host => bare.to_owned(),
_ => instance,
}
}
fn canonical_namespace(provider: &ExternalProvider, namespace: &str) -> String {
let rules = provider.rules();
let folded = if rules.folds_namespace_case {
namespace.to_lowercase()
} else {
namespace.to_owned()
};
to_fixed_point(folded, |text| {
let joined = text
.trim()
.split('/')
.filter(|segment| !matches!(*segment, "" | "."))
.collect::<Vec<_>>()
.join("/");
match joined.strip_suffix(".git") {
Some(repository) if rules.strips_git_suffix => repository.to_owned(),
_ => joined,
}
})
}
fn strip_leading_zeros(digits: &str) -> &str {
let trimmed = digits.trim_start_matches('0');
if trimmed.is_empty() && !digits.is_empty() {
"0"
} else {
trimmed
}
}
fn canonical_external_id(form: IdForm, external_id: &str) -> String {
let prefixes: &[char] = match form {
IdForm::Number => &['#', '!', '&'],
IdForm::Key | IdForm::Raw => &['#', '!'],
};
let external_id = to_fixed_point(external_id.to_owned(), |text| {
let text = text.trim();
text.strip_prefix(prefixes).unwrap_or(text).to_owned()
});
let numeric = |text: &str| !text.is_empty() && text.chars().all(|c| c.is_ascii_digit());
match form {
IdForm::Number if numeric(&external_id) => strip_leading_zeros(&external_id).to_owned(),
IdForm::Key => {
let upper = external_id.to_uppercase();
match upper.rsplit_once('-') {
Some((project, number)) if numeric(number) => {
format!("{project}-{}", strip_leading_zeros(number))
}
_ => upper,
}
}
IdForm::Number | IdForm::Raw => external_id,
}
}
#[cfg(test)]
mod tests;