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        return IdType::Pmc;
39    }
40    // PMID with prefix: PMID:NNNNNNN(N)
41    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    // Semantic Scholar: S2:<hex>
48    if input.starts_with("S2:") && input.len() > 3 && input[3..].chars().all(|c| c.is_ascii_hexdigit()) {
49        return IdType::S2;
50    }
51    // URL
52    if input.starts_with("http://") || input.starts_with("https://") {
53        return IdType::Url;
54    }
55    // Bare 7-8 digit number (possible PMID)
56    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}