Skip to main content

aspero_core/id/
doi.rs

1use std::borrow::Cow;
2use std::fmt;
3
4use super::IdKind;
5use super::syntax::{IdSyntax, clean_pasted, impl_id_traits, strip_kind_prefix, strip_prefix_ci};
6use super::url::Url;
7
8const INFO_PREFIX: &str = "info:doi/";
9
10/// Sentence punctuation that clings to DOIs copied from prose ("see
11/// 10.1145/3368089."). Not `)`: SICI-style suffixes can legitimately end in one.
12const TRAILING_PUNCTUATION: &[char] = &['.', ',', ';', ':'];
13
14/// A Digital Object Identifier, e.g. `10.1145/3368089`.
15///
16/// Canonical form is ASCII-lowercased (DOIs are case-insensitive over ASCII;
17/// non-ASCII characters are left untouched) with no `doi:` or URL wrapper.
18/// URL forms are percent-decoded; bare and prefixed forms are taken literally.
19///
20/// Trailing `.` `,` `;` `:` are stripped. The DOI spec technically permits a
21/// suffix ending in one, but in practice that is far rarer than a DOI copied
22/// with the end of its sentence, and a wrong DOI is caught later by lookup.
23#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
24pub struct Doi(String);
25
26impl Doi {
27    pub fn as_str(&self) -> &str {
28        &self.0
29    }
30
31    /// The registrant part, e.g. `10.1145`.
32    pub fn prefix(&self) -> &str {
33        self.split().0
34    }
35
36    /// Everything after the first `/`.
37    pub fn suffix(&self) -> &str {
38        self.split().1
39    }
40
41    fn split(&self) -> (&str, &str) {
42        self.0.split_once('/').expect("canonical DOI contains '/'")
43    }
44}
45
46impl Doi {
47    /// The DOI as written *with* the trailing punctuation that parsing
48    /// strips, if `input` had any (`"10.1145/x."` → `10.1145/x.`). DOIs may
49    /// legally end in `.`; callers retry with this when the stripped form
50    /// isn't found.
51    ///
52    /// The one exception to the parse/display round trip: parsing this
53    /// value's display strips the punctuation again. Use it for lookups only.
54    pub fn with_trailing_punctuation(input: &str) -> Option<Doi> {
55        let stripped: Doi = input.parse().ok()?;
56        let lower = input.trim().to_ascii_lowercase();
57        let at = lower.rfind(stripped.as_str())?;
58        let tail = &lower[at + stripped.as_str().len()..];
59        let only_punctuation =
60            !tail.is_empty() && tail.chars().all(|c| TRAILING_PUNCTUATION.contains(&c));
61        only_punctuation.then(|| Doi(format!("{}{tail}", stripped.as_str())))
62    }
63}
64
65/// What publishers append to a DOI in their page URLs. Checked longest
66/// first; only ever stripped from publisher URLs, never from a DOI as given.
67const LANDING_SUFFIXES: &[&str] = &[
68    "/full.pdf",
69    ".full.pdf",
70    "/fulltext",
71    "/abstract",
72    "/pdfdirect",
73    ".abstract",
74    "/epdf",
75    "/full",
76    "/html",
77    "/meta",
78    "/pdf",
79    ".full",
80    ".pdf",
81];
82
83/// `doi` without a trailing landing-page suffix (`/full`, `.pdf`, …).
84fn without_landing_suffix(doi: &str) -> &str {
85    let lower = doi.to_ascii_lowercase();
86    LANDING_SUFFIXES
87        .iter()
88        .find(|s| lower.ends_with(*s))
89        .map_or(doi, |s| &doi[..doi.len() - s.len()])
90}
91
92pub(crate) fn has_info_prefix(s: &str) -> bool {
93    strip_prefix_ci(s, INFO_PREFIX).is_some()
94}
95
96impl IdSyntax for Doi {
97    const KIND: IdKind = IdKind::Doi;
98
99    fn parse_bare(s: &str) -> Result<Self, &'static str> {
100        // Also after URL decoding: canonical DOIs never end in what pasting
101        // adds, so display → parse is the identity.
102        let doi = clean_pasted(s)
103            .trim_end_matches(TRAILING_PUNCTUATION)
104            .to_ascii_lowercase();
105        let (prefix, suffix) = doi
106            .split_once('/')
107            .ok_or("missing '/' between prefix and suffix")?;
108        let registrant = prefix
109            .strip_prefix("10.")
110            .ok_or("must start with \"10.\"")?;
111        let mut groups = registrant.split('.');
112        let first = groups.next().unwrap_or_default();
113        let digits = |g: &str| !g.is_empty() && g.bytes().all(|b| b.is_ascii_digit());
114        if !(4..=9).contains(&first.len()) || !digits(first) || !groups.all(digits) {
115            return Err("registrant must be digits, e.g. 10.1234 or 10.1234.5");
116        }
117        if suffix.is_empty() {
118            return Err("empty suffix");
119        }
120        if suffix.chars().any(|c| c.is_whitespace() || c.is_control()) {
121            return Err("suffix contains whitespace or control characters");
122        }
123        Ok(Doi(doi))
124    }
125
126    /// doi.org URLs, whose path is the DOI, and publishers' pages with the
127    /// DOI in the path (`frontiersin.org/articles/10.3389/…/full`), whose
128    /// landing-page suffix is dropped.
129    fn from_url<'a>(url: &Url<'a>) -> Option<Result<Cow<'a, str>, &'static str>> {
130        let decode = |path: &'a str| {
131            percent_encoding::percent_decode_str(path)
132                .decode_utf8()
133                .map_err(|_| "not valid UTF-8 after percent-decoding")
134        };
135        if url.is_host(&["doi.org", "dx.doi.org"]) {
136            return Some(decode(url.path));
137        }
138        // A path segment starting "10.<digits>/" begins the DOI.
139        let at = url.path.match_indices("10.").map(|(i, _)| i).find(|&i| {
140            let registrant = url.path[i + 3..].split('/').next().unwrap_or("");
141            (i == 0 || url.path[..i].ends_with('/'))
142                && registrant.starts_with(|c: char| c.is_ascii_digit())
143                && registrant.bytes().all(|b| b.is_ascii_digit() || b == b'.')
144                && url.path[i + 3 + registrant.len()..].len() > 1
145        })?;
146        Some(decode(&url.path[at..]).map(|doi| Cow::Owned(without_landing_suffix(&doi).to_owned())))
147    }
148
149    fn strip_prefix(s: &str) -> &str {
150        strip_prefix_ci(s, INFO_PREFIX).unwrap_or_else(|| strip_kind_prefix(s, "doi"))
151    }
152}
153
154impl_id_traits!(Doi);
155
156impl fmt::Display for Doi {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        f.write_str(&self.0)
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::id::IdError;
166
167    fn doi(s: &str) -> Result<Doi, IdError> {
168        s.parse()
169    }
170
171    #[test]
172    fn normalises_surface_forms() {
173        for input in [
174            "10.1145/3368089.3409741",
175            "  DOI:10.1145/3368089.3409741 ",
176            "doi: 10.1145/3368089.3409741",
177            "https://doi.org/10.1145/3368089.3409741",
178            "http://dx.doi.org/10.1145/3368089.3409741",
179            "doi.org/10.1145/3368089.3409741",
180            "info:doi/10.1145/3368089.3409741",
181        ] {
182            assert_eq!(
183                doi(input).unwrap().as_str(),
184                "10.1145/3368089.3409741",
185                "{input:?}"
186            );
187        }
188    }
189
190    #[test]
191    fn lowercases_ascii_only() {
192        assert_eq!(doi("10.1002/ANIE.É").unwrap().as_str(), "10.1002/anie.É");
193    }
194
195    #[test]
196    fn percent_decodes_urls_only() {
197        assert_eq!(
198            doi("https://doi.org/10.1002/%28SICI%291097-4636")
199                .unwrap()
200                .as_str(),
201            "10.1002/(sici)1097-4636"
202        );
203        assert_eq!(doi("10.1002/a%20b").unwrap().as_str(), "10.1002/a%20b");
204    }
205
206    #[test]
207    fn decoded_url_dois_are_cleaned_too() {
208        assert_eq!(
209            doi("https://doi.org/10.1234/x%29").unwrap().as_str(),
210            "10.1234/x"
211        );
212        assert_eq!(
213            doi("https://doi.org/10.1016/S0140-6736%2810%2960835-5")
214                .unwrap()
215                .as_str(),
216            "10.1016/s0140-6736(10)60835-5"
217        );
218    }
219
220    #[test]
221    fn query_and_fragment_are_not_part_of_url_doi() {
222        assert_eq!(
223            doi("https://doi.org/10.1145/3368089?foo=bar#x")
224                .unwrap()
225                .as_str(),
226            "10.1145/3368089"
227        );
228    }
229
230    #[test]
231    fn strips_trailing_sentence_punctuation() {
232        for input in [
233            "10.1145/3368089.",
234            "10.1145/3368089.,",
235            "doi:10.1145/3368089;",
236            "https://doi.org/10.1145/3368089.",
237            "10.1145/3368089:",
238        ] {
239            assert_eq!(doi(input).unwrap().as_str(), "10.1145/3368089", "{input:?}");
240        }
241        assert_eq!(
242            doi("10.1002/(sici)1097-4636(199601)").unwrap().suffix(),
243            "(sici)1097-4636(199601)"
244        );
245        assert!(doi("10.1145/.").is_err());
246    }
247
248    #[test]
249    fn publisher_urls_give_their_doi_without_page_suffixes() {
250        for (url, want) in [
251            (
252                "https://www.frontiersin.org/articles/10.3389/fneur.2019.01343/full",
253                "10.3389/fneur.2019.01343",
254            ),
255            (
256                "https://storage.imrpress.com/IMR/hmed/application/10.12968/hmed.2007.68.sup2.22842.pdf",
257                "10.12968/hmed.2007.68.sup2.22842",
258            ),
259            (
260                "https://onlinelibrary.wiley.com/doi/epdf/10.1002/14651858.CD000567.pub7",
261                "10.1002/14651858.cd000567.pub7",
262            ),
263            (
264                "https://www.nejm.org/doi/full/10.1056/NEJMoa1302097?articleTools=true",
265                "10.1056/nejmoa1302097",
266            ),
267            (
268                "https://www.thelancet.com/journals/lancet/article/PIIS0140-6736(10)60835-5/fulltext",
269                "",
270            ),
271        ] {
272            match doi(url) {
273                Ok(d) => assert_eq!(d.as_str(), want, "{url}"),
274                Err(_) => assert_eq!(want, "", "{url}"),
275            }
276        }
277        // At doi.org the path is the DOI, suffix-like endings included.
278        assert_eq!(
279            doi("https://doi.org/10.1234/report.pdf").unwrap().as_str(),
280            "10.1234/report.pdf"
281        );
282    }
283
284    #[test]
285    fn keeps_trailing_punctuation_on_request() {
286        let kept = Doi::with_trailing_punctuation("doi:10.1145/3368089.").unwrap();
287        assert_eq!(kept.as_str(), "10.1145/3368089.");
288        assert_eq!(
289            Doi::with_trailing_punctuation("https://doi.org/10.1145/3368089;")
290                .unwrap()
291                .as_str(),
292            "10.1145/3368089;"
293        );
294        assert_eq!(Doi::with_trailing_punctuation("10.1145/3368089"), None);
295    }
296
297    #[test]
298    fn splits_prefix_and_suffix() {
299        let d = doi("10.48550/arXiv.1706.03762").unwrap();
300        assert_eq!(d.prefix(), "10.48550");
301        assert_eq!(d.suffix(), "arxiv.1706.03762");
302    }
303
304    #[test]
305    fn rejects_malformed() {
306        for input in [
307            "10.1145",
308            "11.1145/x",
309            "10.12/x",
310            "10.abcd/x",
311            "10.1145/",
312            "10.1145/a b",
313            "https://example.org/papers/x",
314        ] {
315            assert!(
316                matches!(
317                    doi(input),
318                    Err(IdError::Invalid {
319                        kind: IdKind::Doi,
320                        ..
321                    })
322                ),
323                "{input:?} -> {:?}",
324                doi(input)
325            );
326        }
327    }
328}