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