1use std::borrow::Cow;
2use std::fmt;
3
4use super::IdKind;
5use super::syntax::{IdSyntax, clean_pasted, impl_id_traits, strip_kind_prefix, strip_prefix_ci};
6use super::url::Url;
7
8const INFO_PREFIX: &str = "info:doi/";
9
10const TRAILING_PUNCTUATION: &[char] = &['.', ',', ';', ':'];
13
14#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
24pub struct Doi(String);
25
26impl Doi {
27 pub fn as_str(&self) -> &str {
28 &self.0
29 }
30
31 pub fn prefix(&self) -> &str {
33 self.split().0
34 }
35
36 pub fn suffix(&self) -> &str {
38 self.split().1
39 }
40
41 fn split(&self) -> (&str, &str) {
42 self.0.split_once('/').expect("canonical DOI contains '/'")
43 }
44}
45
46impl Doi {
47 pub fn with_trailing_punctuation(input: &str) -> Option<Doi> {
55 let stripped: Doi = input.parse().ok()?;
56 let lower = input.trim().to_ascii_lowercase();
57 let at = lower.rfind(stripped.as_str())?;
58 let tail = &lower[at + stripped.as_str().len()..];
59 let only_punctuation =
60 !tail.is_empty() && tail.chars().all(|c| TRAILING_PUNCTUATION.contains(&c));
61 only_punctuation.then(|| Doi(format!("{}{tail}", stripped.as_str())))
62 }
63}
64
65const LANDING_SUFFIXES: &[&str] = &[
68 "/full.pdf",
69 ".full.pdf",
70 "/fulltext",
71 "/abstract",
72 "/pdfdirect",
73 ".abstract",
74 "/epdf",
75 "/full",
76 "/html",
77 "/meta",
78 "/pdf",
79 ".full",
80 ".pdf",
81];
82
83fn without_landing_suffix(doi: &str) -> &str {
85 let lower = doi.to_ascii_lowercase();
86 LANDING_SUFFIXES
87 .iter()
88 .find(|s| lower.ends_with(*s))
89 .map_or(doi, |s| &doi[..doi.len() - s.len()])
90}
91
92pub(crate) fn has_info_prefix(s: &str) -> bool {
93 strip_prefix_ci(s, INFO_PREFIX).is_some()
94}
95
96impl IdSyntax for Doi {
97 const KIND: IdKind = IdKind::Doi;
98
99 fn parse_bare(s: &str) -> Result<Self, &'static str> {
100 let doi = clean_pasted(s)
103 .trim_end_matches(TRAILING_PUNCTUATION)
104 .to_ascii_lowercase();
105 let (prefix, suffix) = doi
106 .split_once('/')
107 .ok_or("missing '/' between prefix and suffix")?;
108 let registrant = prefix
109 .strip_prefix("10.")
110 .ok_or("must start with \"10.\"")?;
111 let mut groups = registrant.split('.');
112 let first = groups.next().unwrap_or_default();
113 let digits = |g: &str| !g.is_empty() && g.bytes().all(|b| b.is_ascii_digit());
114 if !(4..=9).contains(&first.len()) || !digits(first) || !groups.all(digits) {
115 return Err("registrant must be digits, e.g. 10.1234 or 10.1234.5");
116 }
117 if suffix.is_empty() {
118 return Err("empty suffix");
119 }
120 if suffix.chars().any(|c| c.is_whitespace() || c.is_control()) {
121 return Err("suffix contains whitespace or control characters");
122 }
123 Ok(Doi(doi))
124 }
125
126 fn from_url<'a>(url: &Url<'a>) -> Option<Result<Cow<'a, str>, &'static str>> {
130 let decode = |path: &'a str| {
131 percent_encoding::percent_decode_str(path)
132 .decode_utf8()
133 .map_err(|_| "not valid UTF-8 after percent-decoding")
134 };
135 if url.is_host(&["doi.org", "dx.doi.org"]) {
136 return Some(decode(url.path));
137 }
138 let at = url.path.match_indices("10.").map(|(i, _)| i).find(|&i| {
140 let registrant = url.path[i + 3..].split('/').next().unwrap_or("");
141 (i == 0 || url.path[..i].ends_with('/'))
142 && registrant.starts_with(|c: char| c.is_ascii_digit())
143 && registrant.bytes().all(|b| b.is_ascii_digit() || b == b'.')
144 && url.path[i + 3 + registrant.len()..].len() > 1
145 })?;
146 Some(decode(&url.path[at..]).map(|doi| Cow::Owned(without_landing_suffix(&doi).to_owned())))
147 }
148
149 fn strip_prefix(s: &str) -> &str {
150 strip_prefix_ci(s, INFO_PREFIX).unwrap_or_else(|| strip_kind_prefix(s, "doi"))
151 }
152}
153
154impl_id_traits!(Doi);
155
156impl fmt::Display for Doi {
157 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158 f.write_str(&self.0)
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use crate::id::IdError;
166
167 fn doi(s: &str) -> Result<Doi, IdError> {
168 s.parse()
169 }
170
171 #[test]
172 fn normalises_surface_forms() {
173 for input in [
174 "10.1145/3368089.3409741",
175 " DOI:10.1145/3368089.3409741 ",
176 "doi: 10.1145/3368089.3409741",
177 "https://doi.org/10.1145/3368089.3409741",
178 "http://dx.doi.org/10.1145/3368089.3409741",
179 "doi.org/10.1145/3368089.3409741",
180 "info:doi/10.1145/3368089.3409741",
181 ] {
182 assert_eq!(
183 doi(input).unwrap().as_str(),
184 "10.1145/3368089.3409741",
185 "{input:?}"
186 );
187 }
188 }
189
190 #[test]
191 fn lowercases_ascii_only() {
192 assert_eq!(doi("10.1002/ANIE.É").unwrap().as_str(), "10.1002/anie.É");
193 }
194
195 #[test]
196 fn percent_decodes_urls_only() {
197 assert_eq!(
198 doi("https://doi.org/10.1002/%28SICI%291097-4636")
199 .unwrap()
200 .as_str(),
201 "10.1002/(sici)1097-4636"
202 );
203 assert_eq!(doi("10.1002/a%20b").unwrap().as_str(), "10.1002/a%20b");
204 }
205
206 #[test]
207 fn decoded_url_dois_are_cleaned_too() {
208 assert_eq!(
209 doi("https://doi.org/10.1234/x%29").unwrap().as_str(),
210 "10.1234/x"
211 );
212 assert_eq!(
213 doi("https://doi.org/10.1016/S0140-6736%2810%2960835-5")
214 .unwrap()
215 .as_str(),
216 "10.1016/s0140-6736(10)60835-5"
217 );
218 }
219
220 #[test]
221 fn query_and_fragment_are_not_part_of_url_doi() {
222 assert_eq!(
223 doi("https://doi.org/10.1145/3368089?foo=bar#x")
224 .unwrap()
225 .as_str(),
226 "10.1145/3368089"
227 );
228 }
229
230 #[test]
231 fn strips_trailing_sentence_punctuation() {
232 for input in [
233 "10.1145/3368089.",
234 "10.1145/3368089.,",
235 "doi:10.1145/3368089;",
236 "https://doi.org/10.1145/3368089.",
237 "10.1145/3368089:",
238 ] {
239 assert_eq!(doi(input).unwrap().as_str(), "10.1145/3368089", "{input:?}");
240 }
241 assert_eq!(
242 doi("10.1002/(sici)1097-4636(199601)").unwrap().suffix(),
243 "(sici)1097-4636(199601)"
244 );
245 assert!(doi("10.1145/.").is_err());
246 }
247
248 #[test]
249 fn publisher_urls_give_their_doi_without_page_suffixes() {
250 for (url, want) in [
251 (
252 "https://www.frontiersin.org/articles/10.3389/fneur.2019.01343/full",
253 "10.3389/fneur.2019.01343",
254 ),
255 (
256 "https://storage.imrpress.com/IMR/hmed/application/10.12968/hmed.2007.68.sup2.22842.pdf",
257 "10.12968/hmed.2007.68.sup2.22842",
258 ),
259 (
260 "https://onlinelibrary.wiley.com/doi/epdf/10.1002/14651858.CD000567.pub7",
261 "10.1002/14651858.cd000567.pub7",
262 ),
263 (
264 "https://www.nejm.org/doi/full/10.1056/NEJMoa1302097?articleTools=true",
265 "10.1056/nejmoa1302097",
266 ),
267 (
268 "https://www.thelancet.com/journals/lancet/article/PIIS0140-6736(10)60835-5/fulltext",
269 "",
270 ),
271 ] {
272 match doi(url) {
273 Ok(d) => assert_eq!(d.as_str(), want, "{url}"),
274 Err(_) => assert_eq!(want, "", "{url}"),
275 }
276 }
277 assert_eq!(
279 doi("https://doi.org/10.1234/report.pdf").unwrap().as_str(),
280 "10.1234/report.pdf"
281 );
282 }
283
284 #[test]
285 fn keeps_trailing_punctuation_on_request() {
286 let kept = Doi::with_trailing_punctuation("doi:10.1145/3368089.").unwrap();
287 assert_eq!(kept.as_str(), "10.1145/3368089.");
288 assert_eq!(
289 Doi::with_trailing_punctuation("https://doi.org/10.1145/3368089;")
290 .unwrap()
291 .as_str(),
292 "10.1145/3368089;"
293 );
294 assert_eq!(Doi::with_trailing_punctuation("10.1145/3368089"), None);
295 }
296
297 #[test]
298 fn splits_prefix_and_suffix() {
299 let d = doi("10.48550/arXiv.1706.03762").unwrap();
300 assert_eq!(d.prefix(), "10.48550");
301 assert_eq!(d.suffix(), "arxiv.1706.03762");
302 }
303
304 #[test]
305 fn rejects_malformed() {
306 for input in [
307 "10.1145",
308 "11.1145/x",
309 "10.12/x",
310 "10.abcd/x",
311 "10.1145/",
312 "10.1145/a b",
313 "https://example.org/papers/x",
314 ] {
315 assert!(
316 matches!(
317 doi(input),
318 Err(IdError::Invalid {
319 kind: IdKind::Doi,
320 ..
321 })
322 ),
323 "{input:?} -> {:?}",
324 doi(input)
325 );
326 }
327 }
328}