Skip to main content

fastpaper/
identifier.rs

1/// The type of identifier detected from user input.
2#[derive(Debug, Clone, PartialEq)]
3pub enum IdType {
4    /// arXiv new format: 2301.08745, 2301.08745v2
5    Arxiv,
6    /// arXiv old format: hep-th/9711200
7    ArxivOld,
8    /// DOI: 10.xxxx/...
9    Doi,
10    /// PMC ID: PMC7318926
11    Pmc,
12    /// PubMed ID: PMID:12345678 or bare 7-8 digit number
13    Pmid,
14    /// Semantic Scholar ID: S2:abc123
15    S2,
16    /// URL (will be routed by domain)
17    Url,
18    /// Unknown format
19    Unknown,
20}
21
22/// Detect the identifier type from a string.
23pub fn detect_id_type(input: &str) -> IdType {
24    // arXiv new format: YYMM.NNNNN(vN)
25    if is_arxiv_new(input) {
26        return IdType::Arxiv;
27    }
28    // arXiv old format: category/NNNNNNN
29    if is_arxiv_old(input) {
30        return IdType::ArxivOld;
31    }
32    // DOI: 10.NNNN/...
33    if is_doi(input) {
34        return IdType::Doi;
35    }
36    // PMC ID: PMC followed by digits
37    if input.starts_with("PMC") && input.len() > 3 && input[3..].chars().all(|c| c.is_ascii_digit())
38    {
39        return IdType::Pmc;
40    }
41    // PMID with prefix: PMID:NNNNNNN(N)
42    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    // Semantic Scholar: S2:<hex>
49    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    // URL
56    if input.starts_with("http://") || input.starts_with("https://") {
57        return IdType::Url;
58    }
59    // Bare 7-8 digit number (possible PMID)
60    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}