Skip to main content

aspero_core/id/
mod.rs

1//! Scholarly work identifiers: parsing, normalisation and canonical display.
2//!
3//! Contract shared by every identifier type here:
4//!
5//! - `FromStr` accepts the forms people and providers actually write: bare
6//!   (`10.1145/3368089`), `kind:`-prefixed with any case (`DOI: 10.1145/…`,
7//!   `PMID:123`, `CorpusId:123`), and the kind's well-known URLs
8//!   (`https://doi.org/…`, `arxiv.org/abs/…`).
9//! - A parsed value is canonical. `Display` prints the canonical form, and
10//!   parsing that output yields an equal value, so normalisation is idempotent.
11//! - Equality is equality of canonical forms.
12//! - Parsing is strict about structure, with one concession to pasted prose:
13//!   wrappers and trailing sentence punctuation come off (see [`Doi`]).
14//!   Finding identifiers in running text is [`scan`]'s job.
15//!
16//! [`WorkId`] is the kind-tagged union. Its canonical form is `kind:value`
17//! (`doi:10.1145/3368089`), which is also how it serialises.
18
19mod arxiv;
20mod doi;
21mod isbn;
22mod numeric;
23mod s2;
24mod scan;
25mod syntax;
26mod url;
27
28use std::fmt;
29use std::str::FromStr;
30
31pub use arxiv::ArxivId;
32pub use doi::Doi;
33pub use isbn::Isbn;
34pub use numeric::{CorpusId, Nbk, OpenAlexId, Pmcid, Pmid};
35pub use s2::S2PaperId;
36pub use scan::{Mention, scan};
37
38use syntax::IdSyntax;
39pub(crate) use syntax::{is_digits, starts_with_ci};
40use url::Url;
41
42/// The kinds of work identifier aspero understands.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
44#[non_exhaustive]
45pub enum IdKind {
46    Doi,
47    Arxiv,
48    Pmid,
49    Pmcid,
50    OpenAlex,
51    S2Paper,
52    CorpusId,
53    Isbn,
54    /// NCBI Bookshelf.
55    Nbk,
56}
57
58impl IdKind {
59    /// Every kind. A slice, so adding a kind is not a breaking change.
60    pub const ALL: &[IdKind] = &[
61        IdKind::Doi,
62        IdKind::Arxiv,
63        IdKind::Pmid,
64        IdKind::Pmcid,
65        IdKind::OpenAlex,
66        IdKind::S2Paper,
67        IdKind::CorpusId,
68        IdKind::Isbn,
69        IdKind::Nbk,
70    ];
71
72    /// The lowercase prefix used in `kind:value` form.
73    pub fn prefix(self) -> &'static str {
74        match self {
75            IdKind::Doi => "doi",
76            IdKind::Arxiv => "arxiv",
77            IdKind::Pmid => "pmid",
78            IdKind::Pmcid => "pmcid",
79            IdKind::OpenAlex => "openalex",
80            IdKind::S2Paper => "s2",
81            IdKind::CorpusId => "corpusid",
82            IdKind::Isbn => "isbn",
83            IdKind::Nbk => "nbk",
84        }
85    }
86
87    /// Case-insensitive inverse of [`IdKind::prefix`].
88    pub fn from_prefix(s: &str) -> Option<Self> {
89        Self::ALL
90            .iter()
91            .copied()
92            .find(|k| k.prefix().eq_ignore_ascii_case(s))
93    }
94
95    /// The kind a label written before an identifier names: `PMID`,
96    /// `PMID:`, `ISBN-13`, `doi:`, …
97    pub fn from_label(label: &str) -> Option<Self> {
98        const LABELS: &[(&str, IdKind)] = &[
99            ("pmid", IdKind::Pmid),
100            ("pmcid", IdKind::Pmcid),
101            ("doi", IdKind::Doi),
102            ("arxiv", IdKind::Arxiv),
103            ("nbk", IdKind::Nbk),
104            ("isbn", IdKind::Isbn),
105            ("isbn-13", IdKind::Isbn),
106            ("isbn-10", IdKind::Isbn),
107            ("isbn13", IdKind::Isbn),
108            ("isbn10", IdKind::Isbn),
109        ];
110        let label = label.trim_end_matches(':');
111        LABELS
112            .iter()
113            .find(|(name, _)| name.eq_ignore_ascii_case(label))
114            .map(|&(_, kind)| kind)
115    }
116}
117
118impl fmt::Display for IdKind {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        f.write_str(self.prefix())
121    }
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
125#[non_exhaustive]
126pub enum IdError {
127    #[error("empty identifier")]
128    Empty,
129    #[error("unrecognised identifier {0:?}")]
130    Unrecognised(String),
131    #[error(
132        "ambiguous identifier {input:?}: prefix it with one of {}",
133        prefixes(.candidates)
134    )]
135    Ambiguous {
136        input: String,
137        candidates: Vec<IdKind>,
138    },
139    #[error("invalid {kind} {input:?}: {reason}")]
140    Invalid {
141        kind: IdKind,
142        input: String,
143        reason: &'static str,
144    },
145}
146
147fn prefixes(kinds: &[IdKind]) -> String {
148    kinds
149        .iter()
150        .map(|k| format!("{k}:"))
151        .collect::<Vec<_>>()
152        .join(", ")
153}
154
155/// Any supported work identifier, tagged with its kind.
156#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
157#[non_exhaustive]
158pub enum WorkId {
159    Doi(Doi),
160    Arxiv(ArxivId),
161    Pmid(Pmid),
162    Pmcid(Pmcid),
163    OpenAlex(OpenAlexId),
164    S2Paper(S2PaperId),
165    CorpusId(CorpusId),
166    Isbn(Isbn),
167    Nbk(Nbk),
168}
169
170impl WorkId {
171    pub fn kind(&self) -> IdKind {
172        match self {
173            WorkId::Doi(_) => IdKind::Doi,
174            WorkId::Arxiv(_) => IdKind::Arxiv,
175            WorkId::Pmid(_) => IdKind::Pmid,
176            WorkId::Pmcid(_) => IdKind::Pmcid,
177            WorkId::OpenAlex(_) => IdKind::OpenAlex,
178            WorkId::S2Paper(_) => IdKind::S2Paper,
179            WorkId::CorpusId(_) => IdKind::CorpusId,
180            WorkId::Isbn(_) => IdKind::Isbn,
181            WorkId::Nbk(_) => IdKind::Nbk,
182        }
183    }
184
185    /// Parses `input` as a specific kind, skipping detection. Accepts the same
186    /// forms as that kind's own `FromStr`.
187    pub fn parse_as(kind: IdKind, input: &str) -> Result<Self, IdError> {
188        Ok(match kind {
189            IdKind::Doi => WorkId::Doi(input.parse()?),
190            IdKind::Arxiv => WorkId::Arxiv(input.parse()?),
191            IdKind::Pmid => WorkId::Pmid(input.parse()?),
192            IdKind::Pmcid => WorkId::Pmcid(input.parse()?),
193            IdKind::OpenAlex => WorkId::OpenAlex(input.parse()?),
194            IdKind::S2Paper => WorkId::S2Paper(input.parse()?),
195            IdKind::CorpusId => WorkId::CorpusId(input.parse()?),
196            IdKind::Isbn => WorkId::Isbn(input.parse()?),
197            IdKind::Nbk => WorkId::Nbk(input.parse()?),
198        })
199    }
200
201    fn from_url(url: &Url<'_>, input: &str) -> Result<Self, IdError> {
202        fn try_kind<T: IdSyntax>(
203            url: &Url<'_>,
204            input: &str,
205            wrap: fn(T) -> WorkId,
206        ) -> Option<Result<WorkId, IdError>> {
207            T::from_url(url).map(|bare| syntax::finish::<T>(bare, input).map(wrap))
208        }
209
210        try_kind(url, input, WorkId::Doi)
211            .or_else(|| try_kind(url, input, WorkId::Arxiv))
212            .or_else(|| try_kind(url, input, WorkId::Pmid))
213            .or_else(|| try_kind(url, input, WorkId::Pmcid))
214            .or_else(|| try_kind(url, input, WorkId::OpenAlex))
215            .or_else(|| try_kind(url, input, WorkId::S2Paper))
216            .or_else(|| try_kind(url, input, WorkId::CorpusId))
217            .or_else(|| try_kind(url, input, WorkId::Isbn))
218            .or_else(|| try_kind(url, input, WorkId::Nbk))
219            .unwrap_or_else(|| Err(IdError::Unrecognised(input.to_owned())))
220    }
221
222    /// Guesses the kind of an unprefixed, non-URL identifier.
223    fn detect_bare(s: &str, input: &str) -> Result<Self, IdError> {
224        if s.starts_with("10.") || doi::has_info_prefix(s) {
225            return WorkId::parse_as(IdKind::Doi, input);
226        }
227        // "ISBN 978-…" / "ISBN-13 …": a label followed by a space.
228        if starts_with_ci(s, "isbn") {
229            return WorkId::parse_as(IdKind::Isbn, input);
230        }
231        if starts_with_ci(s, "pmc") && is_digits(&s[3..]) {
232            return WorkId::parse_as(IdKind::Pmcid, input);
233        }
234        if starts_with_ci(s, "nbk") && is_digits(&s[3..]) {
235            return WorkId::parse_as(IdKind::Nbk, input);
236        }
237        if s.starts_with(['W', 'w']) && is_digits(&s[1..]) {
238            return WorkId::parse_as(IdKind::OpenAlex, input);
239        }
240        // 13 digits from the book prefixes with a valid checksum, or a
241        // hyphenated form, can only be an ISBN; plain 10 digits might be.
242        let isbn = Isbn::parse_bare(s).is_ok();
243        if isbn {
244            let compact: String = s.chars().filter(|c| !matches!(c, '-' | ' ')).collect();
245            let book_prefix = compact.starts_with("978") || compact.starts_with("979");
246            if (compact.len() == 13 && book_prefix) || s.contains('-') {
247                return WorkId::parse_as(IdKind::Isbn, input);
248            }
249        }
250        if is_digits(s) {
251            let mut candidates = vec![IdKind::Pmid, IdKind::CorpusId];
252            if isbn {
253                candidates.push(IdKind::Isbn);
254            }
255            return Err(IdError::Ambiguous {
256                input: input.to_owned(),
257                candidates,
258            });
259        }
260        if s.len() == 40 && s.bytes().all(|b| b.is_ascii_hexdigit()) {
261            return WorkId::parse_as(IdKind::S2Paper, input);
262        }
263        if let Ok(id) = s.parse::<ArxivId>() {
264            return Ok(WorkId::Arxiv(id));
265        }
266        Err(IdError::Unrecognised(input.to_owned()))
267    }
268}
269
270/// The identifier a work is best known by, the same whichever of its
271/// identifiers it was found through: a DOI (other than arXiv's DataCite
272/// one), then arXiv, PMID, PMCID, ISBN, NBK, any DOI, any other.
273pub fn canonical(ids: &[WorkId]) -> Option<&WorkId> {
274    let arxiv_doi = |id: &&WorkId| matches!(id, WorkId::Doi(d) if d.prefix() == "10.48550");
275    let of = |kind: IdKind| ids.iter().find(move |id| id.kind() == kind);
276    ids.iter()
277        .find(|id| id.kind() == IdKind::Doi && !arxiv_doi(id))
278        .or_else(|| {
279            [
280                IdKind::Arxiv,
281                IdKind::Pmid,
282                IdKind::Pmcid,
283                IdKind::Isbn,
284                IdKind::Nbk,
285                IdKind::Doi,
286            ]
287            .into_iter()
288            .find_map(of)
289        })
290        .or_else(|| ids.first())
291}
292
293impl FromStr for WorkId {
294    type Err = IdError;
295
296    /// Detects the kind: URL host first, then an explicit `kind:` prefix or
297    /// a label (`PMID 123`, `ISBN 978-…`, as [`scan`] finds them), then the
298    /// shape of a bare value. Bare all-digit input is ambiguous (PMID or
299    /// S2 CorpusId) and is rejected rather than guessed. Look-alike
300    /// characters are cleaned first, as [`scan`] does.
301    fn from_str(input: &str) -> Result<Self, IdError> {
302        // Look-alikes from PDFs and word processors (a Unicode hyphen in a
303        // DOI, a zero-width space) are what they look like.
304        let folded = crate::fold::Folded::new(input);
305        if folded.text != input {
306            return folded.text.parse();
307        }
308        let s = syntax::clean_pasted(input);
309        if s.is_empty() {
310            return Err(IdError::Empty);
311        }
312        if let Some(url) = Url::parse(s) {
313            return Self::from_url(&url, input);
314        }
315        if let Some((head, _)) = s.split_once(':')
316            && let Some(kind) = IdKind::from_prefix(head.trim())
317        {
318            return Self::parse_as(kind, input);
319        }
320        if let Some((head, rest)) = s.split_once(':')
321            && let Some(kind) = IdKind::from_label(head.trim())
322        {
323            return Self::parse_as(kind, rest.trim());
324        }
325        if let Some((head, rest)) = s.split_once(char::is_whitespace)
326            && let Some(kind) = IdKind::from_label(head)
327        {
328            return Self::parse_as(kind, rest.trim());
329        }
330        Self::detect_bare(s, input)
331    }
332}
333
334impl fmt::Display for WorkId {
335    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
336        write!(f, "{}:", self.kind())?;
337        match self {
338            WorkId::Doi(id) => id.fmt(f),
339            WorkId::Arxiv(id) => id.fmt(f),
340            WorkId::Pmid(id) => id.fmt(f),
341            WorkId::Pmcid(id) => id.fmt(f),
342            WorkId::OpenAlex(id) => id.fmt(f),
343            WorkId::S2Paper(id) => id.fmt(f),
344            WorkId::CorpusId(id) => id.fmt(f),
345            WorkId::Isbn(id) => id.fmt(f),
346            WorkId::Nbk(id) => id.fmt(f),
347        }
348    }
349}
350
351syntax::impl_string_serde!(WorkId);
352
353macro_rules! impl_from_for_work_id {
354    ($($variant:ident($ty:ty)),* $(,)?) => {
355        $(impl From<$ty> for WorkId {
356            fn from(id: $ty) -> Self {
357                WorkId::$variant(id)
358            }
359        })*
360    };
361}
362
363impl_from_for_work_id!(
364    Doi(Doi),
365    Arxiv(ArxivId),
366    Pmid(Pmid),
367    Pmcid(Pmcid),
368    OpenAlex(OpenAlexId),
369    S2Paper(S2PaperId),
370    CorpusId(CorpusId),
371    Isbn(Isbn),
372    Nbk(Nbk),
373);
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    fn parse(s: &str) -> Result<WorkId, IdError> {
380        s.parse()
381    }
382
383    #[test]
384    fn look_alikes_parse_as_what_they_look_like() {
385        let id: WorkId = "10.1038/s41586\u{2010}020\u{200B}-2649-2".parse().unwrap();
386        assert_eq!(id.to_string(), "doi:10.1038/s41586-020-2649-2");
387    }
388
389    #[test]
390    fn detects_kind_from_bare_prefixed_and_url_forms() {
391        let cases = [
392            ("10.1145/3368089", "doi:10.1145/3368089"),
393            ("https://doi.org/10.1145/3368089", "doi:10.1145/3368089"),
394            ("info:doi/10.1145/3368089", "doi:10.1145/3368089"),
395            ("1706.03762v5", "arxiv:1706.03762v5"),
396            ("arXiv:hep-th/9901001", "arxiv:hep-th/9901001"),
397            ("https://arxiv.org/pdf/1706.03762", "arxiv:1706.03762"),
398            ("PMID: 31452104", "pmid:31452104"),
399            ("https://pubmed.ncbi.nlm.nih.gov/31452104/", "pmid:31452104"),
400            ("PMC6716493", "pmcid:PMC6716493"),
401            ("NBK526056", "nbk:NBK526056"),
402            (
403                "https://www.ncbi.nlm.nih.gov/books/NBK526056/",
404                "nbk:NBK526056",
405            ),
406            ("W2741809807", "openalex:W2741809807"),
407            ("https://openalex.org/W2741809807", "openalex:W2741809807"),
408            ("CorpusId:13756489", "corpusid:13756489"),
409            (
410                "https://api.semanticscholar.org/CorpusID:13756489",
411                "corpusid:13756489",
412            ),
413            (
414                "204E3073870FAE3D05BCBC2F6A8E263D9B72E776",
415                "s2:204e3073870fae3d05bcbc2f6a8e263d9b72e776",
416            ),
417        ];
418        for (input, want) in cases {
419            assert_eq!(parse(input).unwrap().to_string(), want, "input {input:?}");
420        }
421    }
422
423    #[test]
424    fn bare_digits_are_ambiguous() {
425        assert_eq!(
426            parse("31452104"),
427            Err(IdError::Ambiguous {
428                input: "31452104".into(),
429                candidates: vec![IdKind::Pmid, IdKind::CorpusId],
430            })
431        );
432    }
433
434    #[test]
435    fn rejects_empty_and_unknown() {
436        assert_eq!(parse("  "), Err(IdError::Empty));
437        assert!(matches!(
438            parse("attention is all you need"),
439            Err(IdError::Unrecognised(_))
440        ));
441        assert!(matches!(
442            parse("https://example.com/paper/1"),
443            Err(IdError::Unrecognised(_))
444        ));
445    }
446
447    #[test]
448    fn explicit_prefix_reports_kind_specific_errors() {
449        assert!(matches!(
450            parse("doi:11.1234/x"),
451            Err(IdError::Invalid {
452                kind: IdKind::Doi,
453                ..
454            })
455        ));
456    }
457
458    #[test]
459    fn canonical_ids_prefer_the_publisher_doi() {
460        let ids =
461            |list: &[&str]| -> Vec<WorkId> { list.iter().map(|s| parse(s).unwrap()).collect() };
462        let pick = |list: &[&str]| canonical(&ids(list)).map(ToString::to_string);
463        // Found by DOI (OpenAlex) or by PMCID (Europe PMC): the same key.
464        assert_eq!(
465            pick(&[
466                "openalex:W4211043869",
467                "doi:10.1016/s0140-6736(19)32233-0",
468                "pmid:31623894"
469            ]),
470            pick(&[
471                "pmid:31623894",
472                "pmcid:PMC6853170",
473                "doi:10.1016/s0140-6736(19)32233-0"
474            ]),
475        );
476        assert_eq!(
477            pick(&["doi:10.48550/arxiv.1706.03762", "arxiv:1706.03762"]).as_deref(),
478            Some("arxiv:1706.03762")
479        );
480        assert_eq!(pick(&["pmcid:PMC1", "pmid:2"]).as_deref(), Some("pmid:2"));
481        assert_eq!(pick(&[]), None);
482    }
483
484    #[test]
485    fn serialises_as_prefixed_string() {
486        let id = parse("10.1145/3368089").unwrap();
487        let json = serde_json::to_string(&id).unwrap();
488        assert_eq!(json, r#""doi:10.1145/3368089""#);
489        assert_eq!(serde_json::from_str::<WorkId>(&json).unwrap(), id);
490    }
491}