Skip to main content

dpm_model/tracking/
canonical.rs

1//! Spelling normalization, the single object key used by validation, the engine, adapter lookup
2//! and reviewed plan changes, and the single kind-change rule; all three read the provider table.
3
4use super::provider::IdForm;
5use super::{ExternalIdentity, ExternalObjectKind, ExternalProvider};
6
7/// What decides whether two records name one external object.
8///
9/// It is the provider family (Gitea and Forgejo are one family), instance, namespace, the number
10/// space of the kind in the provider table, and the normalized identifier. Kinds sharing a number
11/// space (GitHub issues, pull requests and discussions; GitLab issues, incidents and tasks; every
12/// Jira or Linear issue type) share one key.
13#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub struct ObjectKey {
15    /// Provider family.
16    pub family: ExternalProvider,
17    /// Canonical `host[:port]`.
18    pub instance: String,
19    /// Canonical namespace, where the provider scopes identifiers.
20    pub namespace: Option<String>,
21    /// Number space of the kind; `None` when the provider does not accept the kind.
22    pub space: Option<String>,
23    /// Normalized identifier.
24    pub id: String,
25}
26
27impl ExternalIdentity {
28    /// Normalize spelling that providers treat as equal; validation rejects any other form.
29    ///
30    /// Every rule strips to a fixed point, so `canonical` is idempotent and the spelling a
31    /// rejection suggests is always accepted.
32    #[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    /// The key of the object this identity names, computed on its canonical form.
54    #[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    /// Whether a record of this identity may be restated with `next`'s kind: unchanged, or
67    /// another kind of the same number space unless the record is a pull request. A pull request
68    /// is the most specific kind of its number, so it is never downgraded; a kind of another
69    /// number space names another object. Linking and reviewed plan changes both apply this rule.
70    #[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    /// Whether this identity names `recorded`'s object with a kind the record may be restated to.
84    #[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
92/// Repeat a normalization step until it changes nothing; every step only removes text, so the
93/// loop terminates.
94fn to_fixed_point(text: String, step: impl Fn(&str) -> String) -> String {
95    let mut current = text;
96    loop {
97        let next = step(&current);
98        if next == current {
99            return current;
100        }
101        current = next;
102    }
103}
104
105/// Lowercase `host[:port]` without scheme, trailing slash or trailing root dot; a port loses
106/// leading zeros, and the default HTTP(S) ports `443` and `80` are dropped.
107fn 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
128/// The canonical instance, with `www.` removed from a public host the provider also serves bare.
129pub(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
137/// Slash-separated segments without empty or `.` segments; `..` is kept so validation rejects it.
138fn 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;