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