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