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