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//!   DOIs drop trailing sentence punctuation (see [`Doi`]). Pulling
14//!   identifiers out of running text (line-wrapped DOIs, surrounding words)
15//!   belongs to `resolve`, not here.
16//!
17//! [`WorkId`] is the kind-tagged union. Its canonical form is `kind:value`
18//! (`doi:10.1145/3368089`), which is also how it serialises.
19
20mod arxiv;
21mod doi;
22mod isbn;
23mod numeric;
24mod s2;
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, OpenAlexId, Pmcid, Pmid};
35pub use s2::S2PaperId;
36
37use syntax::IdSyntax;
38use url::Url;
39
40/// The kinds of work identifier aspero understands.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
42#[non_exhaustive]
43pub enum IdKind {
44    Doi,
45    Arxiv,
46    Pmid,
47    Pmcid,
48    OpenAlex,
49    S2Paper,
50    CorpusId,
51    Isbn,
52}
53
54impl IdKind {
55    pub const ALL: [IdKind; 8] = [
56        IdKind::Doi,
57        IdKind::Arxiv,
58        IdKind::Pmid,
59        IdKind::Pmcid,
60        IdKind::OpenAlex,
61        IdKind::S2Paper,
62        IdKind::CorpusId,
63        IdKind::Isbn,
64    ];
65
66    /// The lowercase prefix used in `kind:value` form.
67    pub fn prefix(self) -> &'static str {
68        match self {
69            IdKind::Doi => "doi",
70            IdKind::Arxiv => "arxiv",
71            IdKind::Pmid => "pmid",
72            IdKind::Pmcid => "pmcid",
73            IdKind::OpenAlex => "openalex",
74            IdKind::S2Paper => "s2",
75            IdKind::CorpusId => "corpusid",
76            IdKind::Isbn => "isbn",
77        }
78    }
79
80    /// Case-insensitive inverse of [`IdKind::prefix`].
81    pub fn from_prefix(s: &str) -> Option<Self> {
82        Self::ALL
83            .into_iter()
84            .find(|k| k.prefix().eq_ignore_ascii_case(s))
85    }
86}
87
88impl fmt::Display for IdKind {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        f.write_str(self.prefix())
91    }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
95#[non_exhaustive]
96pub enum IdError {
97    #[error("empty identifier")]
98    Empty,
99    #[error("unrecognised identifier {0:?}")]
100    Unrecognised(String),
101    #[error(
102        "ambiguous identifier {input:?}: prefix it with one of {}",
103        prefixes(.candidates)
104    )]
105    Ambiguous {
106        input: String,
107        candidates: Vec<IdKind>,
108    },
109    #[error("invalid {kind} {input:?}: {reason}")]
110    Invalid {
111        kind: IdKind,
112        input: String,
113        reason: &'static str,
114    },
115}
116
117fn prefixes(kinds: &[IdKind]) -> String {
118    kinds
119        .iter()
120        .map(|k| format!("{k}:"))
121        .collect::<Vec<_>>()
122        .join(", ")
123}
124
125/// Any supported work identifier, tagged with its kind.
126#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
127#[non_exhaustive]
128pub enum WorkId {
129    Doi(Doi),
130    Arxiv(ArxivId),
131    Pmid(Pmid),
132    Pmcid(Pmcid),
133    OpenAlex(OpenAlexId),
134    S2Paper(S2PaperId),
135    CorpusId(CorpusId),
136    Isbn(Isbn),
137}
138
139impl WorkId {
140    pub fn kind(&self) -> IdKind {
141        match self {
142            WorkId::Doi(_) => IdKind::Doi,
143            WorkId::Arxiv(_) => IdKind::Arxiv,
144            WorkId::Pmid(_) => IdKind::Pmid,
145            WorkId::Pmcid(_) => IdKind::Pmcid,
146            WorkId::OpenAlex(_) => IdKind::OpenAlex,
147            WorkId::S2Paper(_) => IdKind::S2Paper,
148            WorkId::CorpusId(_) => IdKind::CorpusId,
149            WorkId::Isbn(_) => IdKind::Isbn,
150        }
151    }
152
153    /// Parses `input` as a specific kind, skipping detection. Accepts the same
154    /// forms as that kind's own `FromStr`.
155    pub fn parse_as(kind: IdKind, input: &str) -> Result<Self, IdError> {
156        Ok(match kind {
157            IdKind::Doi => WorkId::Doi(input.parse()?),
158            IdKind::Arxiv => WorkId::Arxiv(input.parse()?),
159            IdKind::Pmid => WorkId::Pmid(input.parse()?),
160            IdKind::Pmcid => WorkId::Pmcid(input.parse()?),
161            IdKind::OpenAlex => WorkId::OpenAlex(input.parse()?),
162            IdKind::S2Paper => WorkId::S2Paper(input.parse()?),
163            IdKind::CorpusId => WorkId::CorpusId(input.parse()?),
164            IdKind::Isbn => WorkId::Isbn(input.parse()?),
165        })
166    }
167
168    fn from_url(url: &Url<'_>, input: &str) -> Result<Self, IdError> {
169        fn try_kind<T: IdSyntax>(
170            url: &Url<'_>,
171            input: &str,
172            wrap: fn(T) -> WorkId,
173        ) -> Option<Result<WorkId, IdError>> {
174            T::from_url(url).map(|bare| syntax::finish::<T>(bare, input).map(wrap))
175        }
176
177        try_kind(url, input, WorkId::Doi)
178            .or_else(|| try_kind(url, input, WorkId::Arxiv))
179            .or_else(|| try_kind(url, input, WorkId::Pmid))
180            .or_else(|| try_kind(url, input, WorkId::Pmcid))
181            .or_else(|| try_kind(url, input, WorkId::OpenAlex))
182            .or_else(|| try_kind(url, input, WorkId::S2Paper))
183            .or_else(|| try_kind(url, input, WorkId::CorpusId))
184            .or_else(|| try_kind(url, input, WorkId::Isbn))
185            .unwrap_or_else(|| Err(IdError::Unrecognised(input.to_owned())))
186    }
187
188    /// Guesses the kind of an unprefixed, non-URL identifier.
189    fn detect_bare(s: &str, input: &str) -> Result<Self, IdError> {
190        let all_digits = |t: &str| !t.is_empty() && t.bytes().all(|b| b.is_ascii_digit());
191
192        if s.starts_with("10.") || doi::has_info_prefix(s) {
193            return WorkId::parse_as(IdKind::Doi, input);
194        }
195        // "ISBN 978-…" / "ISBN-13 …": a label followed by a space.
196        if s.get(..4).is_some_and(|p| p.eq_ignore_ascii_case("isbn")) {
197            return WorkId::parse_as(IdKind::Isbn, input);
198        }
199        if s.get(..3).is_some_and(|p| p.eq_ignore_ascii_case("pmc")) && all_digits(&s[3..]) {
200            return WorkId::parse_as(IdKind::Pmcid, input);
201        }
202        if s.starts_with(['W', 'w']) && all_digits(&s[1..]) {
203            return WorkId::parse_as(IdKind::OpenAlex, input);
204        }
205        // 13 digits from the book prefixes with a valid checksum, or a
206        // hyphenated form, can only be an ISBN; plain 10 digits might be.
207        let compact: String = s.chars().filter(|c| !matches!(c, '-' | ' ')).collect();
208        let book_prefix = compact.starts_with("978") || compact.starts_with("979");
209        if isbn::looks_like_isbn(s) && ((compact.len() == 13 && book_prefix) || s.contains('-')) {
210            return WorkId::parse_as(IdKind::Isbn, input);
211        }
212        if all_digits(s) {
213            let mut candidates = vec![IdKind::Pmid, IdKind::CorpusId];
214            if isbn::looks_like_isbn(s) {
215                candidates.push(IdKind::Isbn);
216            }
217            return Err(IdError::Ambiguous {
218                input: input.to_owned(),
219                candidates,
220            });
221        }
222        if s.len() == 40 && s.bytes().all(|b| b.is_ascii_hexdigit()) {
223            return WorkId::parse_as(IdKind::S2Paper, input);
224        }
225        if let Ok(id) = s.parse::<ArxivId>() {
226            return Ok(WorkId::Arxiv(id));
227        }
228        Err(IdError::Unrecognised(input.to_owned()))
229    }
230}
231
232impl FromStr for WorkId {
233    type Err = IdError;
234
235    /// Detects the kind: URL host first, then an explicit `kind:` prefix, then
236    /// the shape of a bare value. Bare all-digit input is ambiguous (PMID or
237    /// S2 CorpusId) and is rejected rather than guessed.
238    fn from_str(input: &str) -> Result<Self, IdError> {
239        let s = input.trim();
240        if s.is_empty() {
241            return Err(IdError::Empty);
242        }
243        if let Some(url) = Url::parse(s) {
244            return Self::from_url(&url, input);
245        }
246        if let Some((head, _)) = s.split_once(':')
247            && let Some(kind) = IdKind::from_prefix(head.trim())
248        {
249            return Self::parse_as(kind, input);
250        }
251        Self::detect_bare(s, input)
252    }
253}
254
255impl fmt::Display for WorkId {
256    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257        write!(f, "{}:", self.kind())?;
258        match self {
259            WorkId::Doi(id) => id.fmt(f),
260            WorkId::Arxiv(id) => id.fmt(f),
261            WorkId::Pmid(id) => id.fmt(f),
262            WorkId::Pmcid(id) => id.fmt(f),
263            WorkId::OpenAlex(id) => id.fmt(f),
264            WorkId::S2Paper(id) => id.fmt(f),
265            WorkId::CorpusId(id) => id.fmt(f),
266            WorkId::Isbn(id) => id.fmt(f),
267        }
268    }
269}
270
271syntax::impl_string_serde!(WorkId);
272
273macro_rules! impl_from_for_work_id {
274    ($($variant:ident($ty:ty)),* $(,)?) => {
275        $(impl From<$ty> for WorkId {
276            fn from(id: $ty) -> Self {
277                WorkId::$variant(id)
278            }
279        })*
280    };
281}
282
283impl_from_for_work_id!(
284    Doi(Doi),
285    Arxiv(ArxivId),
286    Pmid(Pmid),
287    Pmcid(Pmcid),
288    OpenAlex(OpenAlexId),
289    S2Paper(S2PaperId),
290    CorpusId(CorpusId),
291    Isbn(Isbn),
292);
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    fn parse(s: &str) -> Result<WorkId, IdError> {
299        s.parse()
300    }
301
302    #[test]
303    fn detects_kind_from_bare_prefixed_and_url_forms() {
304        let cases = [
305            ("10.1145/3368089", "doi:10.1145/3368089"),
306            ("https://doi.org/10.1145/3368089", "doi:10.1145/3368089"),
307            ("info:doi/10.1145/3368089", "doi:10.1145/3368089"),
308            ("1706.03762v5", "arxiv:1706.03762v5"),
309            ("arXiv:hep-th/9901001", "arxiv:hep-th/9901001"),
310            ("https://arxiv.org/pdf/1706.03762", "arxiv:1706.03762"),
311            ("PMID: 31452104", "pmid:31452104"),
312            ("https://pubmed.ncbi.nlm.nih.gov/31452104/", "pmid:31452104"),
313            ("PMC6716493", "pmcid:PMC6716493"),
314            ("W2741809807", "openalex:W2741809807"),
315            ("https://openalex.org/W2741809807", "openalex:W2741809807"),
316            ("CorpusId:13756489", "corpusid:13756489"),
317            (
318                "https://api.semanticscholar.org/CorpusID:13756489",
319                "corpusid:13756489",
320            ),
321            (
322                "204E3073870FAE3D05BCBC2F6A8E263D9B72E776",
323                "s2:204e3073870fae3d05bcbc2f6a8e263d9b72e776",
324            ),
325        ];
326        for (input, want) in cases {
327            assert_eq!(parse(input).unwrap().to_string(), want, "input {input:?}");
328        }
329    }
330
331    #[test]
332    fn bare_digits_are_ambiguous() {
333        assert_eq!(
334            parse("31452104"),
335            Err(IdError::Ambiguous {
336                input: "31452104".into(),
337                candidates: vec![IdKind::Pmid, IdKind::CorpusId],
338            })
339        );
340    }
341
342    #[test]
343    fn rejects_empty_and_unknown() {
344        assert_eq!(parse("  "), Err(IdError::Empty));
345        assert!(matches!(
346            parse("attention is all you need"),
347            Err(IdError::Unrecognised(_))
348        ));
349        assert!(matches!(
350            parse("https://example.com/paper/1"),
351            Err(IdError::Unrecognised(_))
352        ));
353    }
354
355    #[test]
356    fn explicit_prefix_reports_kind_specific_errors() {
357        assert!(matches!(
358            parse("doi:11.1234/x"),
359            Err(IdError::Invalid {
360                kind: IdKind::Doi,
361                ..
362            })
363        ));
364    }
365
366    #[test]
367    fn serialises_as_prefixed_string() {
368        let id = parse("10.1145/3368089").unwrap();
369        let json = serde_json::to_string(&id).unwrap();
370        assert_eq!(json, r#""doi:10.1145/3368089""#);
371        assert_eq!(serde_json::from_str::<WorkId>(&json).unwrap(), id);
372    }
373}