Skip to main content

aspero_core/id/
numeric.rs

1//! Identifiers that are a positive integer, possibly behind a letter prefix.
2
3use std::borrow::Cow;
4use std::fmt;
5
6use super::IdKind;
7use super::syntax::{IdSyntax, impl_id_traits, parse_positive, strip_prefix_ci};
8use super::url::Url;
9
10macro_rules! numeric_id {
11    ($(#[$meta:meta])* $name:ident, letter = $letter:literal) => {
12        $(#[$meta])*
13        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
14        pub struct $name(u64);
15
16        impl $name {
17            /// `None` for zero, which is never a valid identifier.
18            pub fn new(n: u64) -> Option<Self> {
19                (n > 0).then_some(Self(n))
20            }
21
22            pub fn get(self) -> u64 {
23                self.0
24            }
25
26            /// Strips the optional letter prefix and parses the number.
27            fn parse_number(s: &str) -> Result<Self, &'static str> {
28                let digits = strip_prefix_ci(s, $letter).unwrap_or(s);
29                parse_positive(digits).map(Self)
30            }
31        }
32
33        impl fmt::Display for $name {
34            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35                write!(f, "{}{}", $letter, self.0)
36            }
37        }
38
39        impl_id_traits!($name);
40    };
41}
42
43numeric_id!(
44    /// PubMed ID, e.g. `31452104`.
45    Pmid, letter = ""
46);
47numeric_id!(
48    /// PubMed Central ID, canonically `PMC6716493`. The `PMC` letters are
49    /// optional on input.
50    Pmcid, letter = "PMC"
51);
52numeric_id!(
53    /// OpenAlex work ID, canonically `W2741809807`. The `W` is optional when
54    /// explicitly prefixed (`openalex:2741809807`).
55    OpenAlexId, letter = "W"
56);
57numeric_id!(
58    /// Semantic Scholar CorpusId, e.g. `13756489`.
59    CorpusId, letter = ""
60);
61
62/// The segment after `marker` in the URL path, if present.
63fn segment_after<'a>(url: &Url<'a>, marker: &str) -> Option<&'a str> {
64    let mut segs = url.segments();
65    segs.find(|s| *s == marker)?;
66    segs.next()
67}
68
69impl IdSyntax for Pmid {
70    const KIND: IdKind = IdKind::Pmid;
71
72    fn parse_bare(s: &str) -> Result<Self, &'static str> {
73        Self::parse_number(s)
74    }
75
76    fn from_url<'a>(url: &Url<'a>) -> Option<Result<Cow<'a, str>, &'static str>> {
77        let seg = if url.is_host(&["pubmed.ncbi.nlm.nih.gov"]) {
78            url.segments().next()
79        } else if url.is_host(&["ncbi.nlm.nih.gov"]) {
80            segment_after(url, "pubmed")
81        } else {
82            None
83        };
84        seg.map(|s| Ok(Cow::Borrowed(s)))
85    }
86}
87
88impl IdSyntax for Pmcid {
89    const KIND: IdKind = IdKind::Pmcid;
90
91    fn parse_bare(s: &str) -> Result<Self, &'static str> {
92        Self::parse_number(s)
93    }
94
95    fn from_url<'a>(url: &Url<'a>) -> Option<Result<Cow<'a, str>, &'static str>> {
96        let seg = if url.is_host(&["pmc.ncbi.nlm.nih.gov", "ncbi.nlm.nih.gov"]) {
97            segment_after(url, "articles")
98        } else {
99            None
100        };
101        seg.map(|s| Ok(Cow::Borrowed(s)))
102    }
103}
104
105impl IdSyntax for OpenAlexId {
106    const KIND: IdKind = IdKind::OpenAlex;
107
108    fn parse_bare(s: &str) -> Result<Self, &'static str> {
109        Self::parse_number(s)
110    }
111
112    /// `openalex.org/W…`, `openalex.org/works/W…`, and the same on `api.`.
113    /// Other entity types (authors `A…`, sources `S…`) are not works.
114    fn from_url<'a>(url: &Url<'a>) -> Option<Result<Cow<'a, str>, &'static str>> {
115        if !url.is_host(&["openalex.org", "api.openalex.org"]) {
116            return None;
117        }
118        let mut segs = url.segments();
119        let seg = match segs.next()? {
120            "works" => segs.next()?,
121            s => s,
122        };
123        seg.starts_with(['W', 'w'])
124            .then_some(Ok(Cow::Borrowed(seg)))
125    }
126}
127
128impl IdSyntax for CorpusId {
129    const KIND: IdKind = IdKind::CorpusId;
130
131    fn parse_bare(s: &str) -> Result<Self, &'static str> {
132        Self::parse_number(s)
133    }
134
135    /// Semantic Scholar's short link: `api.semanticscholar.org/CorpusID:123`.
136    fn from_url<'a>(url: &Url<'a>) -> Option<Result<Cow<'a, str>, &'static str>> {
137        if !url.is_host(&["api.semanticscholar.org"]) {
138            return None;
139        }
140        let digits = strip_prefix_ci(url.segments().next()?, "corpusid:")?;
141        Some(Ok(Cow::Borrowed(digits)))
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::id::IdError;
149
150    #[test]
151    fn parses_and_displays() {
152        assert_eq!(
153            "PMID: 0031452104".parse::<Pmid>().unwrap().to_string(),
154            "31452104"
155        );
156        assert_eq!(
157            "https://www.ncbi.nlm.nih.gov/pubmed/31452104"
158                .parse::<Pmid>()
159                .unwrap()
160                .get(),
161            31452104
162        );
163        assert_eq!(
164            "pmc6716493".parse::<Pmcid>().unwrap().to_string(),
165            "PMC6716493"
166        );
167        assert_eq!(
168            "pmcid:6716493".parse::<Pmcid>().unwrap().to_string(),
169            "PMC6716493"
170        );
171        assert_eq!(
172            "https://pmc.ncbi.nlm.nih.gov/articles/PMC6716493/"
173                .parse::<Pmcid>()
174                .unwrap()
175                .get(),
176            6716493
177        );
178        assert_eq!(
179            "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC6716493"
180                .parse::<Pmcid>()
181                .unwrap()
182                .get(),
183            6716493
184        );
185        assert_eq!(
186            "https://api.openalex.org/works/w2741809807"
187                .parse::<OpenAlexId>()
188                .unwrap()
189                .to_string(),
190            "W2741809807"
191        );
192        assert_eq!(
193            "openalex:2741809807".parse::<OpenAlexId>().unwrap().get(),
194            2741809807
195        );
196        assert_eq!(
197            "CorpusID:13756489".parse::<CorpusId>().unwrap().get(),
198            13756489
199        );
200    }
201
202    #[test]
203    fn rejects_zero_non_digits_and_overflow() {
204        for input in ["0", "PMID:abc", "12a", "99999999999999999999999"] {
205            assert!(
206                matches!(input.parse::<Pmid>(), Err(IdError::Invalid { .. })),
207                "{input:?}"
208            );
209        }
210        assert!(
211            "https://openalex.org/A5023888391"
212                .parse::<OpenAlexId>()
213                .is_err()
214        );
215        assert_eq!(Pmid::new(0), None);
216    }
217}