1#[derive(Debug, Clone, PartialEq)]
3pub enum IdType {
4 Arxiv,
6 ArxivOld,
8 Doi,
10 Pmc,
12 Pmid,
14 S2,
16 Url,
18 Unknown,
20}
21
22pub fn detect_id_type(input: &str) -> IdType {
24 if is_arxiv_new(input) {
26 return IdType::Arxiv;
27 }
28 if is_arxiv_old(input) {
30 return IdType::ArxivOld;
31 }
32 if is_doi(input) {
34 return IdType::Doi;
35 }
36 if input.starts_with("PMC") && input.len() > 3 && input[3..].chars().all(|c| c.is_ascii_digit()) {
38 return IdType::Pmc;
39 }
40 if input.starts_with("PMID:") {
42 let digits = &input[5..];
43 if (digits.len() == 7 || digits.len() == 8) && digits.chars().all(|c| c.is_ascii_digit()) {
44 return IdType::Pmid;
45 }
46 }
47 if input.starts_with("S2:") && input.len() > 3 && input[3..].chars().all(|c| c.is_ascii_hexdigit()) {
49 return IdType::S2;
50 }
51 if input.starts_with("http://") || input.starts_with("https://") {
53 return IdType::Url;
54 }
55 if (input.len() == 7 || input.len() == 8) && input.chars().all(|c| c.is_ascii_digit()) {
57 return IdType::Pmid;
58 }
59 IdType::Unknown
60}
61
62fn is_arxiv_new(input: &str) -> bool {
63 let (base, _) = match input.rfind('v') {
64 Some(pos) if input[pos + 1..].chars().all(|c| c.is_ascii_digit())
65 && !input[pos + 1..].is_empty() =>
66 {
67 (&input[..pos], &input[pos..])
68 }
69 _ => (input, ""),
70 };
71 let Some(dot) = base.find('.') else {
72 return false;
73 };
74 let prefix = &base[..dot];
75 let suffix = &base[dot + 1..];
76 prefix.len() == 4
77 && prefix.chars().all(|c| c.is_ascii_digit())
78 && (suffix.len() == 4 || suffix.len() == 5)
79 && suffix.chars().all(|c| c.is_ascii_digit())
80}
81
82fn is_doi(input: &str) -> bool {
83 if !input.starts_with("10.") {
84 return false;
85 }
86 let rest = &input[3..];
87 let Some(slash) = rest.find('/') else {
88 return false;
89 };
90 let prefix = &rest[..slash];
91 let suffix = &rest[slash + 1..];
92 prefix.len() >= 4
93 && prefix.chars().all(|c| c.is_ascii_digit())
94 && !suffix.is_empty()
95 && !suffix.chars().any(|c| c.is_whitespace())
96}
97
98fn is_arxiv_old(input: &str) -> bool {
99 let Some(slash) = input.find('/') else {
100 return false;
101 };
102 let cat = &input[..slash];
103 let num = &input[slash + 1..];
104 !cat.is_empty()
105 && cat.chars().all(|c| c.is_ascii_lowercase() || c == '-')
106 && num.len() == 7
107 && num.chars().all(|c| c.is_ascii_digit())
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 #[test]
115 fn detect_arxiv_new_format() {
116 assert_eq!(detect_id_type("2301.08745"), IdType::Arxiv);
117 }
118
119 #[test]
120 fn detect_arxiv_new_format_with_version() {
121 assert_eq!(detect_id_type("2301.08745v2"), IdType::Arxiv);
122 }
123
124 #[test]
125 fn detect_arxiv_old_format() {
126 assert_eq!(detect_id_type("hep-th/9711200"), IdType::ArxivOld);
127 }
128
129 #[test]
130 fn detect_doi() {
131 assert_eq!(detect_id_type("10.1038/nature12373"), IdType::Doi);
132 }
133
134 #[test]
135 fn detect_pmc() {
136 assert_eq!(detect_id_type("PMC7318926"), IdType::Pmc);
137 }
138
139 #[test]
140 fn detect_pmid_with_prefix() {
141 assert_eq!(detect_id_type("PMID:33475315"), IdType::Pmid);
142 }
143
144 #[test]
145 fn detect_pmid_bare_digits() {
146 assert_eq!(detect_id_type("33475315"), IdType::Pmid);
147 }
148
149 #[test]
150 fn detect_url_arxiv() {
151 assert_eq!(detect_id_type("https://arxiv.org/abs/2301.08745"), IdType::Url);
152 }
153
154 #[test]
155 fn detect_url_doi() {
156 assert_eq!(detect_id_type("https://doi.org/10.1038/nature12373"), IdType::Url);
157 }
158
159 #[test]
160 fn detect_semantic_scholar() {
161 assert_eq!(detect_id_type("S2:abc123def"), IdType::S2);
162 }
163
164 #[test]
165 fn detect_unknown() {
166 assert_eq!(detect_id_type("some random string"), IdType::Unknown);
167 }
168}