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