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