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);
61
62fn 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 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 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}