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);
61numeric_id!(
62    /// NCBI Bookshelf ID, canonically `NBK526056`: a book or chapter, such
63    /// as a StatPearls article. The `NBK` letters are optional on input.
64    Nbk, letter = "NBK"
65);
66
67/// The segment after `marker` in the URL path, if present.
68fn segment_after<'a>(url: &Url<'a>, marker: &str) -> Option<&'a str> {
69    let mut segs = url.segments();
70    segs.find(|s| *s == marker)?;
71    segs.next()
72}
73
74impl IdSyntax for Pmid {
75    const KIND: IdKind = IdKind::Pmid;
76
77    fn parse_bare(s: &str) -> Result<Self, &'static str> {
78        Self::parse_number(s)
79    }
80
81    fn from_url<'a>(url: &Url<'a>) -> Option<Result<Cow<'a, str>, &'static str>> {
82        let seg = if url.is_host(&["pubmed.ncbi.nlm.nih.gov"]) {
83            url.segments().next()
84        } else if url.is_host(&["ncbi.nlm.nih.gov"]) {
85            segment_after(url, "pubmed")
86        } else {
87            None
88        };
89        seg.map(|s| Ok(Cow::Borrowed(s)))
90    }
91}
92
93impl IdSyntax for Pmcid {
94    const KIND: IdKind = IdKind::Pmcid;
95
96    fn parse_bare(s: &str) -> Result<Self, &'static str> {
97        Self::parse_number(s)
98    }
99
100    fn from_url<'a>(url: &Url<'a>) -> Option<Result<Cow<'a, str>, &'static str>> {
101        let seg = if url.is_host(&["pmc.ncbi.nlm.nih.gov", "ncbi.nlm.nih.gov"]) {
102            segment_after(url, "articles")
103        } else {
104            None
105        };
106        seg.map(|s| Ok(Cow::Borrowed(s)))
107    }
108}
109
110impl IdSyntax for OpenAlexId {
111    const KIND: IdKind = IdKind::OpenAlex;
112
113    fn parse_bare(s: &str) -> Result<Self, &'static str> {
114        Self::parse_number(s)
115    }
116
117    /// `openalex.org/W…`, `openalex.org/works/W…`, and the same on `api.`.
118    /// Other entity types (authors `A…`, sources `S…`) are not works.
119    fn from_url<'a>(url: &Url<'a>) -> Option<Result<Cow<'a, str>, &'static str>> {
120        if !url.is_host(&["openalex.org", "api.openalex.org"]) {
121            return None;
122        }
123        let mut segs = url.segments();
124        let seg = match segs.next()? {
125            "works" => segs.next()?,
126            s => s,
127        };
128        seg.starts_with(['W', 'w'])
129            .then_some(Ok(Cow::Borrowed(seg)))
130    }
131}
132
133impl IdSyntax for CorpusId {
134    const KIND: IdKind = IdKind::CorpusId;
135
136    fn parse_bare(s: &str) -> Result<Self, &'static str> {
137        Self::parse_number(s)
138    }
139
140    /// Semantic Scholar's short link: `api.semanticscholar.org/CorpusID:123`.
141    fn from_url<'a>(url: &Url<'a>) -> Option<Result<Cow<'a, str>, &'static str>> {
142        if !url.is_host(&["api.semanticscholar.org"]) {
143            return None;
144        }
145        let digits = strip_prefix_ci(url.segments().next()?, "corpusid:")?;
146        Some(Ok(Cow::Borrowed(digits)))
147    }
148}
149
150impl IdSyntax for Nbk {
151    const KIND: IdKind = IdKind::Nbk;
152
153    fn parse_bare(s: &str) -> Result<Self, &'static str> {
154        Self::parse_number(s)
155    }
156
157    /// `ncbi.nlm.nih.gov/books/NBK526056/`.
158    fn from_url<'a>(url: &Url<'a>) -> Option<Result<Cow<'a, str>, &'static str>> {
159        if !url.is_host(&["ncbi.nlm.nih.gov"]) {
160            return None;
161        }
162        segment_after(url, "books").map(|s| Ok(Cow::Borrowed(s)))
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::id::IdError;
170
171    #[test]
172    fn parses_and_displays() {
173        assert_eq!(
174            "PMID: 0031452104".parse::<Pmid>().unwrap().to_string(),
175            "31452104"
176        );
177        assert_eq!(
178            "https://www.ncbi.nlm.nih.gov/pubmed/31452104"
179                .parse::<Pmid>()
180                .unwrap()
181                .get(),
182            31452104
183        );
184        assert_eq!(
185            "pmc6716493".parse::<Pmcid>().unwrap().to_string(),
186            "PMC6716493"
187        );
188        assert_eq!(
189            "pmcid:6716493".parse::<Pmcid>().unwrap().to_string(),
190            "PMC6716493"
191        );
192        assert_eq!(
193            "https://pmc.ncbi.nlm.nih.gov/articles/PMC6716493/"
194                .parse::<Pmcid>()
195                .unwrap()
196                .get(),
197            6716493
198        );
199        assert_eq!(
200            "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC6716493"
201                .parse::<Pmcid>()
202                .unwrap()
203                .get(),
204            6716493
205        );
206        assert_eq!(
207            "https://api.openalex.org/works/w2741809807"
208                .parse::<OpenAlexId>()
209                .unwrap()
210                .to_string(),
211            "W2741809807"
212        );
213        assert_eq!(
214            "openalex:2741809807".parse::<OpenAlexId>().unwrap().get(),
215            2741809807
216        );
217        assert_eq!(
218            "CorpusID:13756489".parse::<CorpusId>().unwrap().get(),
219            13756489
220        );
221        assert_eq!(
222            "https://www.ncbi.nlm.nih.gov/books/NBK526056/"
223                .parse::<Nbk>()
224                .unwrap()
225                .to_string(),
226            "NBK526056"
227        );
228        assert_eq!("nbk526056".parse::<Nbk>().unwrap().get(), 526056);
229    }
230
231    #[test]
232    fn rejects_zero_non_digits_and_overflow() {
233        for input in ["0", "PMID:abc", "12a", "99999999999999999999999"] {
234            assert!(
235                matches!(input.parse::<Pmid>(), Err(IdError::Invalid { .. })),
236                "{input:?}"
237            );
238        }
239        assert!(
240            "https://openalex.org/A5023888391"
241                .parse::<OpenAlexId>()
242                .is_err()
243        );
244        assert_eq!(Pmid::new(0), None);
245    }
246}