1mod arxiv;
20mod doi;
21mod isbn;
22mod numeric;
23mod s2;
24mod scan;
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, Nbk, OpenAlexId, Pmcid, Pmid};
35pub use s2::S2PaperId;
36pub use scan::{Mention, scan};
37
38use syntax::IdSyntax;
39pub(crate) use syntax::{is_digits, starts_with_ci};
40use url::Url;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
44#[non_exhaustive]
45pub enum IdKind {
46 Doi,
47 Arxiv,
48 Pmid,
49 Pmcid,
50 OpenAlex,
51 S2Paper,
52 CorpusId,
53 Isbn,
54 Nbk,
56}
57
58impl IdKind {
59 pub const ALL: &[IdKind] = &[
61 IdKind::Doi,
62 IdKind::Arxiv,
63 IdKind::Pmid,
64 IdKind::Pmcid,
65 IdKind::OpenAlex,
66 IdKind::S2Paper,
67 IdKind::CorpusId,
68 IdKind::Isbn,
69 IdKind::Nbk,
70 ];
71
72 pub fn prefix(self) -> &'static str {
74 match self {
75 IdKind::Doi => "doi",
76 IdKind::Arxiv => "arxiv",
77 IdKind::Pmid => "pmid",
78 IdKind::Pmcid => "pmcid",
79 IdKind::OpenAlex => "openalex",
80 IdKind::S2Paper => "s2",
81 IdKind::CorpusId => "corpusid",
82 IdKind::Isbn => "isbn",
83 IdKind::Nbk => "nbk",
84 }
85 }
86
87 pub fn from_prefix(s: &str) -> Option<Self> {
89 Self::ALL
90 .iter()
91 .copied()
92 .find(|k| k.prefix().eq_ignore_ascii_case(s))
93 }
94
95 pub fn from_label(label: &str) -> Option<Self> {
98 const LABELS: &[(&str, IdKind)] = &[
99 ("pmid", IdKind::Pmid),
100 ("pmcid", IdKind::Pmcid),
101 ("doi", IdKind::Doi),
102 ("arxiv", IdKind::Arxiv),
103 ("nbk", IdKind::Nbk),
104 ("isbn", IdKind::Isbn),
105 ("isbn-13", IdKind::Isbn),
106 ("isbn-10", IdKind::Isbn),
107 ("isbn13", IdKind::Isbn),
108 ("isbn10", IdKind::Isbn),
109 ];
110 let label = label.trim_end_matches(':');
111 LABELS
112 .iter()
113 .find(|(name, _)| name.eq_ignore_ascii_case(label))
114 .map(|&(_, kind)| kind)
115 }
116}
117
118impl fmt::Display for IdKind {
119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120 f.write_str(self.prefix())
121 }
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
125#[non_exhaustive]
126pub enum IdError {
127 #[error("empty identifier")]
128 Empty,
129 #[error("unrecognised identifier {0:?}")]
130 Unrecognised(String),
131 #[error(
132 "ambiguous identifier {input:?}: prefix it with one of {}",
133 prefixes(.candidates)
134 )]
135 Ambiguous {
136 input: String,
137 candidates: Vec<IdKind>,
138 },
139 #[error("invalid {kind} {input:?}: {reason}")]
140 Invalid {
141 kind: IdKind,
142 input: String,
143 reason: &'static str,
144 },
145}
146
147fn prefixes(kinds: &[IdKind]) -> String {
148 kinds
149 .iter()
150 .map(|k| format!("{k}:"))
151 .collect::<Vec<_>>()
152 .join(", ")
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
157#[non_exhaustive]
158pub enum WorkId {
159 Doi(Doi),
160 Arxiv(ArxivId),
161 Pmid(Pmid),
162 Pmcid(Pmcid),
163 OpenAlex(OpenAlexId),
164 S2Paper(S2PaperId),
165 CorpusId(CorpusId),
166 Isbn(Isbn),
167 Nbk(Nbk),
168}
169
170impl WorkId {
171 pub fn kind(&self) -> IdKind {
172 match self {
173 WorkId::Doi(_) => IdKind::Doi,
174 WorkId::Arxiv(_) => IdKind::Arxiv,
175 WorkId::Pmid(_) => IdKind::Pmid,
176 WorkId::Pmcid(_) => IdKind::Pmcid,
177 WorkId::OpenAlex(_) => IdKind::OpenAlex,
178 WorkId::S2Paper(_) => IdKind::S2Paper,
179 WorkId::CorpusId(_) => IdKind::CorpusId,
180 WorkId::Isbn(_) => IdKind::Isbn,
181 WorkId::Nbk(_) => IdKind::Nbk,
182 }
183 }
184
185 pub fn parse_as(kind: IdKind, input: &str) -> Result<Self, IdError> {
188 Ok(match kind {
189 IdKind::Doi => WorkId::Doi(input.parse()?),
190 IdKind::Arxiv => WorkId::Arxiv(input.parse()?),
191 IdKind::Pmid => WorkId::Pmid(input.parse()?),
192 IdKind::Pmcid => WorkId::Pmcid(input.parse()?),
193 IdKind::OpenAlex => WorkId::OpenAlex(input.parse()?),
194 IdKind::S2Paper => WorkId::S2Paper(input.parse()?),
195 IdKind::CorpusId => WorkId::CorpusId(input.parse()?),
196 IdKind::Isbn => WorkId::Isbn(input.parse()?),
197 IdKind::Nbk => WorkId::Nbk(input.parse()?),
198 })
199 }
200
201 fn from_url(url: &Url<'_>, input: &str) -> Result<Self, IdError> {
202 fn try_kind<T: IdSyntax>(
203 url: &Url<'_>,
204 input: &str,
205 wrap: fn(T) -> WorkId,
206 ) -> Option<Result<WorkId, IdError>> {
207 T::from_url(url).map(|bare| syntax::finish::<T>(bare, input).map(wrap))
208 }
209
210 try_kind(url, input, WorkId::Doi)
211 .or_else(|| try_kind(url, input, WorkId::Arxiv))
212 .or_else(|| try_kind(url, input, WorkId::Pmid))
213 .or_else(|| try_kind(url, input, WorkId::Pmcid))
214 .or_else(|| try_kind(url, input, WorkId::OpenAlex))
215 .or_else(|| try_kind(url, input, WorkId::S2Paper))
216 .or_else(|| try_kind(url, input, WorkId::CorpusId))
217 .or_else(|| try_kind(url, input, WorkId::Isbn))
218 .or_else(|| try_kind(url, input, WorkId::Nbk))
219 .unwrap_or_else(|| Err(IdError::Unrecognised(input.to_owned())))
220 }
221
222 fn detect_bare(s: &str, input: &str) -> Result<Self, IdError> {
224 if s.starts_with("10.") || doi::has_info_prefix(s) {
225 return WorkId::parse_as(IdKind::Doi, input);
226 }
227 if starts_with_ci(s, "isbn") {
229 return WorkId::parse_as(IdKind::Isbn, input);
230 }
231 if starts_with_ci(s, "pmc") && is_digits(&s[3..]) {
232 return WorkId::parse_as(IdKind::Pmcid, input);
233 }
234 if starts_with_ci(s, "nbk") && is_digits(&s[3..]) {
235 return WorkId::parse_as(IdKind::Nbk, input);
236 }
237 if s.starts_with(['W', 'w']) && is_digits(&s[1..]) {
238 return WorkId::parse_as(IdKind::OpenAlex, input);
239 }
240 let isbn = Isbn::parse_bare(s).is_ok();
243 if isbn {
244 let compact: String = s.chars().filter(|c| !matches!(c, '-' | ' ')).collect();
245 let book_prefix = compact.starts_with("978") || compact.starts_with("979");
246 if (compact.len() == 13 && book_prefix) || s.contains('-') {
247 return WorkId::parse_as(IdKind::Isbn, input);
248 }
249 }
250 if is_digits(s) {
251 let mut candidates = vec![IdKind::Pmid, IdKind::CorpusId];
252 if isbn {
253 candidates.push(IdKind::Isbn);
254 }
255 return Err(IdError::Ambiguous {
256 input: input.to_owned(),
257 candidates,
258 });
259 }
260 if s.len() == 40 && s.bytes().all(|b| b.is_ascii_hexdigit()) {
261 return WorkId::parse_as(IdKind::S2Paper, input);
262 }
263 if let Ok(id) = s.parse::<ArxivId>() {
264 return Ok(WorkId::Arxiv(id));
265 }
266 Err(IdError::Unrecognised(input.to_owned()))
267 }
268}
269
270pub fn canonical(ids: &[WorkId]) -> Option<&WorkId> {
274 let arxiv_doi = |id: &&WorkId| matches!(id, WorkId::Doi(d) if d.prefix() == "10.48550");
275 let of = |kind: IdKind| ids.iter().find(move |id| id.kind() == kind);
276 ids.iter()
277 .find(|id| id.kind() == IdKind::Doi && !arxiv_doi(id))
278 .or_else(|| {
279 [
280 IdKind::Arxiv,
281 IdKind::Pmid,
282 IdKind::Pmcid,
283 IdKind::Isbn,
284 IdKind::Nbk,
285 IdKind::Doi,
286 ]
287 .into_iter()
288 .find_map(of)
289 })
290 .or_else(|| ids.first())
291}
292
293impl FromStr for WorkId {
294 type Err = IdError;
295
296 fn from_str(input: &str) -> Result<Self, IdError> {
302 let folded = crate::fold::Folded::new(input);
305 if folded.text != input {
306 return folded.text.parse();
307 }
308 let s = syntax::clean_pasted(input);
309 if s.is_empty() {
310 return Err(IdError::Empty);
311 }
312 if let Some(url) = Url::parse(s) {
313 return Self::from_url(&url, input);
314 }
315 if let Some((head, _)) = s.split_once(':')
316 && let Some(kind) = IdKind::from_prefix(head.trim())
317 {
318 return Self::parse_as(kind, input);
319 }
320 if let Some((head, rest)) = s.split_once(':')
321 && let Some(kind) = IdKind::from_label(head.trim())
322 {
323 return Self::parse_as(kind, rest.trim());
324 }
325 if let Some((head, rest)) = s.split_once(char::is_whitespace)
326 && let Some(kind) = IdKind::from_label(head)
327 {
328 return Self::parse_as(kind, rest.trim());
329 }
330 Self::detect_bare(s, input)
331 }
332}
333
334impl fmt::Display for WorkId {
335 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
336 write!(f, "{}:", self.kind())?;
337 match self {
338 WorkId::Doi(id) => id.fmt(f),
339 WorkId::Arxiv(id) => id.fmt(f),
340 WorkId::Pmid(id) => id.fmt(f),
341 WorkId::Pmcid(id) => id.fmt(f),
342 WorkId::OpenAlex(id) => id.fmt(f),
343 WorkId::S2Paper(id) => id.fmt(f),
344 WorkId::CorpusId(id) => id.fmt(f),
345 WorkId::Isbn(id) => id.fmt(f),
346 WorkId::Nbk(id) => id.fmt(f),
347 }
348 }
349}
350
351syntax::impl_string_serde!(WorkId);
352
353macro_rules! impl_from_for_work_id {
354 ($($variant:ident($ty:ty)),* $(,)?) => {
355 $(impl From<$ty> for WorkId {
356 fn from(id: $ty) -> Self {
357 WorkId::$variant(id)
358 }
359 })*
360 };
361}
362
363impl_from_for_work_id!(
364 Doi(Doi),
365 Arxiv(ArxivId),
366 Pmid(Pmid),
367 Pmcid(Pmcid),
368 OpenAlex(OpenAlexId),
369 S2Paper(S2PaperId),
370 CorpusId(CorpusId),
371 Isbn(Isbn),
372 Nbk(Nbk),
373);
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378
379 fn parse(s: &str) -> Result<WorkId, IdError> {
380 s.parse()
381 }
382
383 #[test]
384 fn look_alikes_parse_as_what_they_look_like() {
385 let id: WorkId = "10.1038/s41586\u{2010}020\u{200B}-2649-2".parse().unwrap();
386 assert_eq!(id.to_string(), "doi:10.1038/s41586-020-2649-2");
387 }
388
389 #[test]
390 fn detects_kind_from_bare_prefixed_and_url_forms() {
391 let cases = [
392 ("10.1145/3368089", "doi:10.1145/3368089"),
393 ("https://doi.org/10.1145/3368089", "doi:10.1145/3368089"),
394 ("info:doi/10.1145/3368089", "doi:10.1145/3368089"),
395 ("1706.03762v5", "arxiv:1706.03762v5"),
396 ("arXiv:hep-th/9901001", "arxiv:hep-th/9901001"),
397 ("https://arxiv.org/pdf/1706.03762", "arxiv:1706.03762"),
398 ("PMID: 31452104", "pmid:31452104"),
399 ("https://pubmed.ncbi.nlm.nih.gov/31452104/", "pmid:31452104"),
400 ("PMC6716493", "pmcid:PMC6716493"),
401 ("NBK526056", "nbk:NBK526056"),
402 (
403 "https://www.ncbi.nlm.nih.gov/books/NBK526056/",
404 "nbk:NBK526056",
405 ),
406 ("W2741809807", "openalex:W2741809807"),
407 ("https://openalex.org/W2741809807", "openalex:W2741809807"),
408 ("CorpusId:13756489", "corpusid:13756489"),
409 (
410 "https://api.semanticscholar.org/CorpusID:13756489",
411 "corpusid:13756489",
412 ),
413 (
414 "204E3073870FAE3D05BCBC2F6A8E263D9B72E776",
415 "s2:204e3073870fae3d05bcbc2f6a8e263d9b72e776",
416 ),
417 ];
418 for (input, want) in cases {
419 assert_eq!(parse(input).unwrap().to_string(), want, "input {input:?}");
420 }
421 }
422
423 #[test]
424 fn bare_digits_are_ambiguous() {
425 assert_eq!(
426 parse("31452104"),
427 Err(IdError::Ambiguous {
428 input: "31452104".into(),
429 candidates: vec![IdKind::Pmid, IdKind::CorpusId],
430 })
431 );
432 }
433
434 #[test]
435 fn rejects_empty_and_unknown() {
436 assert_eq!(parse(" "), Err(IdError::Empty));
437 assert!(matches!(
438 parse("attention is all you need"),
439 Err(IdError::Unrecognised(_))
440 ));
441 assert!(matches!(
442 parse("https://example.com/paper/1"),
443 Err(IdError::Unrecognised(_))
444 ));
445 }
446
447 #[test]
448 fn explicit_prefix_reports_kind_specific_errors() {
449 assert!(matches!(
450 parse("doi:11.1234/x"),
451 Err(IdError::Invalid {
452 kind: IdKind::Doi,
453 ..
454 })
455 ));
456 }
457
458 #[test]
459 fn canonical_ids_prefer_the_publisher_doi() {
460 let ids =
461 |list: &[&str]| -> Vec<WorkId> { list.iter().map(|s| parse(s).unwrap()).collect() };
462 let pick = |list: &[&str]| canonical(&ids(list)).map(ToString::to_string);
463 assert_eq!(
465 pick(&[
466 "openalex:W4211043869",
467 "doi:10.1016/s0140-6736(19)32233-0",
468 "pmid:31623894"
469 ]),
470 pick(&[
471 "pmid:31623894",
472 "pmcid:PMC6853170",
473 "doi:10.1016/s0140-6736(19)32233-0"
474 ]),
475 );
476 assert_eq!(
477 pick(&["doi:10.48550/arxiv.1706.03762", "arxiv:1706.03762"]).as_deref(),
478 Some("arxiv:1706.03762")
479 );
480 assert_eq!(pick(&["pmcid:PMC1", "pmid:2"]).as_deref(), Some("pmid:2"));
481 assert_eq!(pick(&[]), None);
482 }
483
484 #[test]
485 fn serialises_as_prefixed_string() {
486 let id = parse("10.1145/3368089").unwrap();
487 let json = serde_json::to_string(&id).unwrap();
488 assert_eq!(json, r#""doi:10.1145/3368089""#);
489 assert_eq!(serde_json::from_str::<WorkId>(&json).unwrap(), id);
490 }
491}