dpm_model/tracking/
canonical.rs1use super::provider::IdForm;
5use super::{ExternalIdentity, ExternalObjectKind, ExternalProvider};
6
7#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub struct ObjectKey {
15 pub family: ExternalProvider,
17 pub instance: String,
19 pub namespace: Option<String>,
21 pub space: Option<String>,
23 pub id: String,
25}
26
27impl ExternalIdentity {
28 #[must_use]
33 pub fn canonical(&self) -> Self {
34 let provider = match &self.provider {
35 ExternalProvider::Other(name) => ExternalProvider::parse(name),
36 known => known.clone(),
37 };
38 let namespace = self
39 .namespace
40 .as_deref()
41 .map(|namespace| canonical_namespace(&provider, namespace))
42 .filter(|namespace| !namespace.is_empty());
43 let rules = provider.rules();
44 Self {
45 instance: canonical_instance_for(&provider, &self.instance),
46 namespace,
47 kind: rules.canonical_kind(&self.kind),
48 external_id: canonical_external_id(rules.ids, &self.external_id),
49 provider,
50 }
51 }
52
53 #[must_use]
55 pub fn object_key(&self) -> ObjectKey {
56 let canonical = self.canonical();
57 ObjectKey {
58 family: canonical.provider.family(),
59 space: canonical.provider.rules().space(&canonical.kind),
60 instance: canonical.instance,
61 namespace: canonical.namespace,
62 id: canonical.external_id,
63 }
64 }
65
66 #[must_use]
71 pub fn permits_kind_change_to(&self, next: &Self) -> bool {
72 let (from, to) = (self.canonical(), next.canonical());
73 if from.kind == to.kind {
74 return true;
75 }
76 let space = |identity: &Self| identity.provider.rules().space(&identity.kind);
77 from.kind != ExternalObjectKind::PullRequest
78 && from.provider.family() == to.provider.family()
79 && space(&from).is_some()
80 && space(&from) == space(&to)
81 }
82
83 #[must_use]
85 pub fn refines_kind_of(&self, recorded: &Self) -> bool {
86 self.canonical().kind != recorded.canonical().kind
87 && recorded.permits_kind_change_to(self)
88 && self.object_key() == recorded.object_key()
89 }
90}
91
92fn to_fixed_point(text: String, step: impl Fn(&str) -> String) -> String {
95 let mut current = text;
96 loop {
97 let next = step(¤t);
98 if next == current {
99 return current;
100 }
101 current = next;
102 }
103}
104
105fn canonical_instance(instance: &str) -> String {
108 to_fixed_point(instance.to_lowercase(), |text| {
109 let text = text.trim();
110 let text = text
111 .strip_prefix("https://")
112 .or_else(|| text.strip_prefix("http://"))
113 .unwrap_or(text)
114 .trim_end_matches('/');
115 let (host, port) = match text.rsplit_once(':') {
116 Some((host, port)) if port.chars().all(|c| c.is_ascii_digit()) => (host, Some(port)),
117 _ => (text, None),
118 };
119 let host = host.strip_suffix('.').unwrap_or(host).trim_end_matches('/');
120 match port.map(|port| (port, port.trim_start_matches('0'))) {
121 None | Some(("", _)) | Some((_, "443" | "80")) => host.to_owned(),
122 Some((_, "")) => format!("{host}:0"),
123 Some((_, port)) => format!("{host}:{port}"),
124 }
125 })
126}
127
128pub(super) fn canonical_instance_for(provider: &ExternalProvider, instance: &str) -> String {
130 let instance = canonical_instance(instance);
131 match (provider.rules().www_host, instance.strip_prefix("www.")) {
132 (Some(host), Some(bare)) if bare == host => bare.to_owned(),
133 _ => instance,
134 }
135}
136
137fn canonical_namespace(provider: &ExternalProvider, namespace: &str) -> String {
139 let rules = provider.rules();
140 let folded = if rules.folds_namespace_case {
141 namespace.to_lowercase()
142 } else {
143 namespace.to_owned()
144 };
145 to_fixed_point(folded, |text| {
146 let joined = text
147 .trim()
148 .split('/')
149 .filter(|segment| !matches!(*segment, "" | "."))
150 .collect::<Vec<_>>()
151 .join("/");
152 match joined.strip_suffix(".git") {
153 Some(repository) if rules.strips_git_suffix => repository.to_owned(),
154 _ => joined,
155 }
156 })
157}
158
159fn strip_leading_zeros(digits: &str) -> &str {
160 let trimmed = digits.trim_start_matches('0');
161 if trimmed.is_empty() && !digits.is_empty() {
162 "0"
163 } else {
164 trimmed
165 }
166}
167
168fn canonical_external_id(form: IdForm, external_id: &str) -> String {
169 let prefixes: &[char] = match form {
170 IdForm::Number => &['#', '!', '&'],
171 IdForm::Key | IdForm::Raw => &['#', '!'],
172 };
173 let external_id = to_fixed_point(external_id.to_owned(), |text| {
174 let text = text.trim();
175 text.strip_prefix(prefixes).unwrap_or(text).to_owned()
176 });
177 let numeric = |text: &str| !text.is_empty() && text.chars().all(|c| c.is_ascii_digit());
178 match form {
179 IdForm::Number if numeric(&external_id) => strip_leading_zeros(&external_id).to_owned(),
180 IdForm::Key => {
181 let upper = external_id.to_uppercase();
182 match upper.rsplit_once('-') {
183 Some((project, number)) if numeric(number) => {
184 format!("{project}-{}", strip_leading_zeros(number))
185 }
186 _ => upper,
187 }
188 }
189 IdForm::Number | IdForm::Raw => external_id,
190 }
191}
192
193#[cfg(test)]
194mod tests;