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