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,
46 letter = ""
47);
48numeric_id!(
49 Pmcid,
52 letter = "PMC"
53);
54numeric_id!(
55 OpenAlexId,
58 letter = "W"
59);
60numeric_id!(
61 CorpusId,
63 letter = ""
64);
65numeric_id!(
66 Nbk,
69 letter = "NBK"
70);
71
72fn 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 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 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 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}