1mod arxiv;
21mod doi;
22mod isbn;
23mod numeric;
24mod s2;
25mod syntax;
26mod url;
27
28use std::fmt;
29use std::str::FromStr;
30
31pub use arxiv::ArxivId;
32pub use doi::Doi;
33pub use isbn::Isbn;
34pub use numeric::{CorpusId, OpenAlexId, Pmcid, Pmid};
35pub use s2::S2PaperId;
36
37use syntax::IdSyntax;
38use url::Url;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
42#[non_exhaustive]
43pub enum IdKind {
44 Doi,
45 Arxiv,
46 Pmid,
47 Pmcid,
48 OpenAlex,
49 S2Paper,
50 CorpusId,
51 Isbn,
52}
53
54impl IdKind {
55 pub const ALL: [IdKind; 8] = [
56 IdKind::Doi,
57 IdKind::Arxiv,
58 IdKind::Pmid,
59 IdKind::Pmcid,
60 IdKind::OpenAlex,
61 IdKind::S2Paper,
62 IdKind::CorpusId,
63 IdKind::Isbn,
64 ];
65
66 pub fn prefix(self) -> &'static str {
68 match self {
69 IdKind::Doi => "doi",
70 IdKind::Arxiv => "arxiv",
71 IdKind::Pmid => "pmid",
72 IdKind::Pmcid => "pmcid",
73 IdKind::OpenAlex => "openalex",
74 IdKind::S2Paper => "s2",
75 IdKind::CorpusId => "corpusid",
76 IdKind::Isbn => "isbn",
77 }
78 }
79
80 pub fn from_prefix(s: &str) -> Option<Self> {
82 Self::ALL
83 .into_iter()
84 .find(|k| k.prefix().eq_ignore_ascii_case(s))
85 }
86}
87
88impl fmt::Display for IdKind {
89 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 f.write_str(self.prefix())
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
95#[non_exhaustive]
96pub enum IdError {
97 #[error("empty identifier")]
98 Empty,
99 #[error("unrecognised identifier {0:?}")]
100 Unrecognised(String),
101 #[error(
102 "ambiguous identifier {input:?}: prefix it with one of {}",
103 prefixes(.candidates)
104 )]
105 Ambiguous {
106 input: String,
107 candidates: Vec<IdKind>,
108 },
109 #[error("invalid {kind} {input:?}: {reason}")]
110 Invalid {
111 kind: IdKind,
112 input: String,
113 reason: &'static str,
114 },
115}
116
117fn prefixes(kinds: &[IdKind]) -> String {
118 kinds
119 .iter()
120 .map(|k| format!("{k}:"))
121 .collect::<Vec<_>>()
122 .join(", ")
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
127#[non_exhaustive]
128pub enum WorkId {
129 Doi(Doi),
130 Arxiv(ArxivId),
131 Pmid(Pmid),
132 Pmcid(Pmcid),
133 OpenAlex(OpenAlexId),
134 S2Paper(S2PaperId),
135 CorpusId(CorpusId),
136 Isbn(Isbn),
137}
138
139impl WorkId {
140 pub fn kind(&self) -> IdKind {
141 match self {
142 WorkId::Doi(_) => IdKind::Doi,
143 WorkId::Arxiv(_) => IdKind::Arxiv,
144 WorkId::Pmid(_) => IdKind::Pmid,
145 WorkId::Pmcid(_) => IdKind::Pmcid,
146 WorkId::OpenAlex(_) => IdKind::OpenAlex,
147 WorkId::S2Paper(_) => IdKind::S2Paper,
148 WorkId::CorpusId(_) => IdKind::CorpusId,
149 WorkId::Isbn(_) => IdKind::Isbn,
150 }
151 }
152
153 pub fn parse_as(kind: IdKind, input: &str) -> Result<Self, IdError> {
156 Ok(match kind {
157 IdKind::Doi => WorkId::Doi(input.parse()?),
158 IdKind::Arxiv => WorkId::Arxiv(input.parse()?),
159 IdKind::Pmid => WorkId::Pmid(input.parse()?),
160 IdKind::Pmcid => WorkId::Pmcid(input.parse()?),
161 IdKind::OpenAlex => WorkId::OpenAlex(input.parse()?),
162 IdKind::S2Paper => WorkId::S2Paper(input.parse()?),
163 IdKind::CorpusId => WorkId::CorpusId(input.parse()?),
164 IdKind::Isbn => WorkId::Isbn(input.parse()?),
165 })
166 }
167
168 fn from_url(url: &Url<'_>, input: &str) -> Result<Self, IdError> {
169 fn try_kind<T: IdSyntax>(
170 url: &Url<'_>,
171 input: &str,
172 wrap: fn(T) -> WorkId,
173 ) -> Option<Result<WorkId, IdError>> {
174 T::from_url(url).map(|bare| syntax::finish::<T>(bare, input).map(wrap))
175 }
176
177 try_kind(url, input, WorkId::Doi)
178 .or_else(|| try_kind(url, input, WorkId::Arxiv))
179 .or_else(|| try_kind(url, input, WorkId::Pmid))
180 .or_else(|| try_kind(url, input, WorkId::Pmcid))
181 .or_else(|| try_kind(url, input, WorkId::OpenAlex))
182 .or_else(|| try_kind(url, input, WorkId::S2Paper))
183 .or_else(|| try_kind(url, input, WorkId::CorpusId))
184 .or_else(|| try_kind(url, input, WorkId::Isbn))
185 .unwrap_or_else(|| Err(IdError::Unrecognised(input.to_owned())))
186 }
187
188 fn detect_bare(s: &str, input: &str) -> Result<Self, IdError> {
190 let all_digits = |t: &str| !t.is_empty() && t.bytes().all(|b| b.is_ascii_digit());
191
192 if s.starts_with("10.") || doi::has_info_prefix(s) {
193 return WorkId::parse_as(IdKind::Doi, input);
194 }
195 if s.get(..4).is_some_and(|p| p.eq_ignore_ascii_case("isbn")) {
197 return WorkId::parse_as(IdKind::Isbn, input);
198 }
199 if s.get(..3).is_some_and(|p| p.eq_ignore_ascii_case("pmc")) && all_digits(&s[3..]) {
200 return WorkId::parse_as(IdKind::Pmcid, input);
201 }
202 if s.starts_with(['W', 'w']) && all_digits(&s[1..]) {
203 return WorkId::parse_as(IdKind::OpenAlex, input);
204 }
205 let compact: String = s.chars().filter(|c| !matches!(c, '-' | ' ')).collect();
208 let book_prefix = compact.starts_with("978") || compact.starts_with("979");
209 if isbn::looks_like_isbn(s) && ((compact.len() == 13 && book_prefix) || s.contains('-')) {
210 return WorkId::parse_as(IdKind::Isbn, input);
211 }
212 if all_digits(s) {
213 let mut candidates = vec![IdKind::Pmid, IdKind::CorpusId];
214 if isbn::looks_like_isbn(s) {
215 candidates.push(IdKind::Isbn);
216 }
217 return Err(IdError::Ambiguous {
218 input: input.to_owned(),
219 candidates,
220 });
221 }
222 if s.len() == 40 && s.bytes().all(|b| b.is_ascii_hexdigit()) {
223 return WorkId::parse_as(IdKind::S2Paper, input);
224 }
225 if let Ok(id) = s.parse::<ArxivId>() {
226 return Ok(WorkId::Arxiv(id));
227 }
228 Err(IdError::Unrecognised(input.to_owned()))
229 }
230}
231
232impl FromStr for WorkId {
233 type Err = IdError;
234
235 fn from_str(input: &str) -> Result<Self, IdError> {
239 let s = syntax::clean_pasted(input);
240 if s.is_empty() {
241 return Err(IdError::Empty);
242 }
243 if let Some(url) = Url::parse(s) {
244 return Self::from_url(&url, input);
245 }
246 if let Some((head, _)) = s.split_once(':')
247 && let Some(kind) = IdKind::from_prefix(head.trim())
248 {
249 return Self::parse_as(kind, input);
250 }
251 Self::detect_bare(s, input)
252 }
253}
254
255impl fmt::Display for WorkId {
256 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257 write!(f, "{}:", self.kind())?;
258 match self {
259 WorkId::Doi(id) => id.fmt(f),
260 WorkId::Arxiv(id) => id.fmt(f),
261 WorkId::Pmid(id) => id.fmt(f),
262 WorkId::Pmcid(id) => id.fmt(f),
263 WorkId::OpenAlex(id) => id.fmt(f),
264 WorkId::S2Paper(id) => id.fmt(f),
265 WorkId::CorpusId(id) => id.fmt(f),
266 WorkId::Isbn(id) => id.fmt(f),
267 }
268 }
269}
270
271syntax::impl_string_serde!(WorkId);
272
273macro_rules! impl_from_for_work_id {
274 ($($variant:ident($ty:ty)),* $(,)?) => {
275 $(impl From<$ty> for WorkId {
276 fn from(id: $ty) -> Self {
277 WorkId::$variant(id)
278 }
279 })*
280 };
281}
282
283impl_from_for_work_id!(
284 Doi(Doi),
285 Arxiv(ArxivId),
286 Pmid(Pmid),
287 Pmcid(Pmcid),
288 OpenAlex(OpenAlexId),
289 S2Paper(S2PaperId),
290 CorpusId(CorpusId),
291 Isbn(Isbn),
292);
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 fn parse(s: &str) -> Result<WorkId, IdError> {
299 s.parse()
300 }
301
302 #[test]
303 fn detects_kind_from_bare_prefixed_and_url_forms() {
304 let cases = [
305 ("10.1145/3368089", "doi:10.1145/3368089"),
306 ("https://doi.org/10.1145/3368089", "doi:10.1145/3368089"),
307 ("info:doi/10.1145/3368089", "doi:10.1145/3368089"),
308 ("1706.03762v5", "arxiv:1706.03762v5"),
309 ("arXiv:hep-th/9901001", "arxiv:hep-th/9901001"),
310 ("https://arxiv.org/pdf/1706.03762", "arxiv:1706.03762"),
311 ("PMID: 31452104", "pmid:31452104"),
312 ("https://pubmed.ncbi.nlm.nih.gov/31452104/", "pmid:31452104"),
313 ("PMC6716493", "pmcid:PMC6716493"),
314 ("W2741809807", "openalex:W2741809807"),
315 ("https://openalex.org/W2741809807", "openalex:W2741809807"),
316 ("CorpusId:13756489", "corpusid:13756489"),
317 (
318 "https://api.semanticscholar.org/CorpusID:13756489",
319 "corpusid:13756489",
320 ),
321 (
322 "204E3073870FAE3D05BCBC2F6A8E263D9B72E776",
323 "s2:204e3073870fae3d05bcbc2f6a8e263d9b72e776",
324 ),
325 ];
326 for (input, want) in cases {
327 assert_eq!(parse(input).unwrap().to_string(), want, "input {input:?}");
328 }
329 }
330
331 #[test]
332 fn bare_digits_are_ambiguous() {
333 assert_eq!(
334 parse("31452104"),
335 Err(IdError::Ambiguous {
336 input: "31452104".into(),
337 candidates: vec![IdKind::Pmid, IdKind::CorpusId],
338 })
339 );
340 }
341
342 #[test]
343 fn rejects_empty_and_unknown() {
344 assert_eq!(parse(" "), Err(IdError::Empty));
345 assert!(matches!(
346 parse("attention is all you need"),
347 Err(IdError::Unrecognised(_))
348 ));
349 assert!(matches!(
350 parse("https://example.com/paper/1"),
351 Err(IdError::Unrecognised(_))
352 ));
353 }
354
355 #[test]
356 fn explicit_prefix_reports_kind_specific_errors() {
357 assert!(matches!(
358 parse("doi:11.1234/x"),
359 Err(IdError::Invalid {
360 kind: IdKind::Doi,
361 ..
362 })
363 ));
364 }
365
366 #[test]
367 fn serialises_as_prefixed_string() {
368 let id = parse("10.1145/3368089").unwrap();
369 let json = serde_json::to_string(&id).unwrap();
370 assert_eq!(json, r#""doi:10.1145/3368089""#);
371 assert_eq!(serde_json::from_str::<WorkId>(&json).unwrap(), id);
372 }
373}