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