aspero_core/id/
numeric.rs1use 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 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 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 Pmid, letter = ""
46);
47numeric_id!(
48 Pmcid, letter = "PMC"
51);
52numeric_id!(
53 OpenAlexId, letter = "W"
56);
57numeric_id!(
58 CorpusId, letter = ""
60);
61numeric_id!(
62 Nbk, letter = "NBK"
65);
66
67fn 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 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 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 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}