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
246impl FromStr for WorkId {
247    type Err = IdError;
248
249    /// Detects the kind: URL host first, then an explicit `kind:` prefix, then
250    /// the shape of a bare value. Bare all-digit input is ambiguous (PMID or
251    /// S2 CorpusId) and is rejected rather than guessed.
252    fn from_str(input: &str) -> Result<Self, IdError> {
253        let s = syntax::clean_pasted(input);
254        if s.is_empty() {
255            return Err(IdError::Empty);
256        }
257        if let Some(url) = Url::parse(s) {
258            return Self::from_url(&url, input);
259        }
260        if let Some((head, _)) = s.split_once(':')
261            && let Some(kind) = IdKind::from_prefix(head.trim())
262        {
263            return Self::parse_as(kind, input);
264        }
265        Self::detect_bare(s, input)
266    }
267}
268
269impl fmt::Display for WorkId {
270    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
271        write!(f, "{}:", self.kind())?;
272        match self {
273            WorkId::Doi(id) => id.fmt(f),
274            WorkId::Arxiv(id) => id.fmt(f),
275            WorkId::Pmid(id) => id.fmt(f),
276            WorkId::Pmcid(id) => id.fmt(f),
277            WorkId::OpenAlex(id) => id.fmt(f),
278            WorkId::S2Paper(id) => id.fmt(f),
279            WorkId::CorpusId(id) => id.fmt(f),
280            WorkId::Isbn(id) => id.fmt(f),
281            WorkId::Nbk(id) => id.fmt(f),
282        }
283    }
284}
285
286syntax::impl_string_serde!(WorkId);
287
288macro_rules! impl_from_for_work_id {
289    ($($variant:ident($ty:ty)),* $(,)?) => {
290        $(impl From<$ty> for WorkId {
291            fn from(id: $ty) -> Self {
292                WorkId::$variant(id)
293            }
294        })*
295    };
296}
297
298impl_from_for_work_id!(
299    Doi(Doi),
300    Arxiv(ArxivId),
301    Pmid(Pmid),
302    Pmcid(Pmcid),
303    OpenAlex(OpenAlexId),
304    S2Paper(S2PaperId),
305    CorpusId(CorpusId),
306    Isbn(Isbn),
307    Nbk(Nbk),
308);
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    fn parse(s: &str) -> Result<WorkId, IdError> {
315        s.parse()
316    }
317
318    #[test]
319    fn detects_kind_from_bare_prefixed_and_url_forms() {
320        let cases = [
321            ("10.1145/3368089", "doi:10.1145/3368089"),
322            ("https://doi.org/10.1145/3368089", "doi:10.1145/3368089"),
323            ("info:doi/10.1145/3368089", "doi:10.1145/3368089"),
324            ("1706.03762v5", "arxiv:1706.03762v5"),
325            ("arXiv:hep-th/9901001", "arxiv:hep-th/9901001"),
326            ("https://arxiv.org/pdf/1706.03762", "arxiv:1706.03762"),
327            ("PMID: 31452104", "pmid:31452104"),
328            ("https://pubmed.ncbi.nlm.nih.gov/31452104/", "pmid:31452104"),
329            ("PMC6716493", "pmcid:PMC6716493"),
330            ("NBK526056", "nbk:NBK526056"),
331            (
332                "https://www.ncbi.nlm.nih.gov/books/NBK526056/",
333                "nbk:NBK526056",
334            ),
335            ("W2741809807", "openalex:W2741809807"),
336            ("https://openalex.org/W2741809807", "openalex:W2741809807"),
337            ("CorpusId:13756489", "corpusid:13756489"),
338            (
339                "https://api.semanticscholar.org/CorpusID:13756489",
340                "corpusid:13756489",
341            ),
342            (
343                "204E3073870FAE3D05BCBC2F6A8E263D9B72E776",
344                "s2:204e3073870fae3d05bcbc2f6a8e263d9b72e776",
345            ),
346        ];
347        for (input, want) in cases {
348            assert_eq!(parse(input).unwrap().to_string(), want, "input {input:?}");
349        }
350    }
351
352    #[test]
353    fn bare_digits_are_ambiguous() {
354        assert_eq!(
355            parse("31452104"),
356            Err(IdError::Ambiguous {
357                input: "31452104".into(),
358                candidates: vec![IdKind::Pmid, IdKind::CorpusId],
359            })
360        );
361    }
362
363    #[test]
364    fn rejects_empty_and_unknown() {
365        assert_eq!(parse("  "), Err(IdError::Empty));
366        assert!(matches!(
367            parse("attention is all you need"),
368            Err(IdError::Unrecognised(_))
369        ));
370        assert!(matches!(
371            parse("https://example.com/paper/1"),
372            Err(IdError::Unrecognised(_))
373        ));
374    }
375
376    #[test]
377    fn explicit_prefix_reports_kind_specific_errors() {
378        assert!(matches!(
379            parse("doi:11.1234/x"),
380            Err(IdError::Invalid {
381                kind: IdKind::Doi,
382                ..
383            })
384        ));
385    }
386
387    #[test]
388    fn serialises_as_prefixed_string() {
389        let id = parse("10.1145/3368089").unwrap();
390        let json = serde_json::to_string(&id).unwrap();
391        assert_eq!(json, r#""doi:10.1145/3368089""#);
392        assert_eq!(serde_json::from_str::<WorkId>(&json).unwrap(), id);
393    }
394}