use std::borrow::Cow;
use std::fmt;
use super::IdKind;
use super::syntax::{IdSyntax, impl_id_traits, strip_kind_prefix, strip_prefix_ci};
use super::url::Url;
const INFO_PREFIX: &str = "info:doi/";
const TRAILING_PUNCTUATION: &[char] = &['.', ',', ';', ':'];
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Doi(String);
impl Doi {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn prefix(&self) -> &str {
self.split().0
}
pub fn suffix(&self) -> &str {
self.split().1
}
fn split(&self) -> (&str, &str) {
self.0.split_once('/').expect("canonical DOI contains '/'")
}
}
impl Doi {
pub fn with_trailing_punctuation(input: &str) -> Option<Doi> {
let stripped: Doi = input.parse().ok()?;
let lower = input.trim().to_ascii_lowercase();
let at = lower.rfind(stripped.as_str())?;
let tail = &lower[at + stripped.as_str().len()..];
let only_punctuation =
!tail.is_empty() && tail.chars().all(|c| TRAILING_PUNCTUATION.contains(&c));
only_punctuation.then(|| Doi(format!("{}{tail}", stripped.as_str())))
}
}
pub(crate) fn has_info_prefix(s: &str) -> bool {
strip_prefix_ci(s, INFO_PREFIX).is_some()
}
impl IdSyntax for Doi {
const KIND: IdKind = IdKind::Doi;
fn parse_bare(s: &str) -> Result<Self, &'static str> {
let doi = s
.trim_end_matches(TRAILING_PUNCTUATION)
.to_ascii_lowercase();
let (prefix, suffix) = doi
.split_once('/')
.ok_or("missing '/' between prefix and suffix")?;
let registrant = prefix
.strip_prefix("10.")
.ok_or("must start with \"10.\"")?;
let mut groups = registrant.split('.');
let first = groups.next().unwrap_or_default();
let digits = |g: &str| !g.is_empty() && g.bytes().all(|b| b.is_ascii_digit());
if !(4..=9).contains(&first.len()) || !digits(first) || !groups.all(digits) {
return Err("registrant must be digits, e.g. 10.1234 or 10.1234.5");
}
if suffix.is_empty() {
return Err("empty suffix");
}
if suffix.chars().any(|c| c.is_whitespace() || c.is_control()) {
return Err("suffix contains whitespace or control characters");
}
Ok(Doi(doi))
}
fn from_url<'a>(url: &Url<'a>) -> Option<Result<Cow<'a, str>, &'static str>> {
url.is_host(&["doi.org", "dx.doi.org"]).then(|| {
percent_encoding::percent_decode_str(url.path)
.decode_utf8()
.map_err(|_| "not valid UTF-8 after percent-decoding")
})
}
fn strip_prefix(s: &str) -> &str {
strip_prefix_ci(s, INFO_PREFIX).unwrap_or_else(|| strip_kind_prefix(s, "doi"))
}
}
impl_id_traits!(Doi);
impl fmt::Display for Doi {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::id::IdError;
fn doi(s: &str) -> Result<Doi, IdError> {
s.parse()
}
#[test]
fn normalises_surface_forms() {
for input in [
"10.1145/3368089.3409741",
" DOI:10.1145/3368089.3409741 ",
"doi: 10.1145/3368089.3409741",
"https://doi.org/10.1145/3368089.3409741",
"http://dx.doi.org/10.1145/3368089.3409741",
"doi.org/10.1145/3368089.3409741",
"info:doi/10.1145/3368089.3409741",
] {
assert_eq!(
doi(input).unwrap().as_str(),
"10.1145/3368089.3409741",
"{input:?}"
);
}
}
#[test]
fn lowercases_ascii_only() {
assert_eq!(doi("10.1002/ANIE.É").unwrap().as_str(), "10.1002/anie.É");
}
#[test]
fn percent_decodes_urls_only() {
assert_eq!(
doi("https://doi.org/10.1002/%28SICI%291097-4636")
.unwrap()
.as_str(),
"10.1002/(sici)1097-4636"
);
assert_eq!(doi("10.1002/a%20b").unwrap().as_str(), "10.1002/a%20b");
}
#[test]
fn query_and_fragment_are_not_part_of_url_doi() {
assert_eq!(
doi("https://doi.org/10.1145/3368089?foo=bar#x")
.unwrap()
.as_str(),
"10.1145/3368089"
);
}
#[test]
fn strips_trailing_sentence_punctuation() {
for input in [
"10.1145/3368089.",
"10.1145/3368089.,",
"doi:10.1145/3368089;",
"https://doi.org/10.1145/3368089.",
"10.1145/3368089:",
] {
assert_eq!(doi(input).unwrap().as_str(), "10.1145/3368089", "{input:?}");
}
assert_eq!(
doi("10.1002/(sici)1097-4636(199601)").unwrap().suffix(),
"(sici)1097-4636(199601)"
);
assert!(doi("10.1145/.").is_err());
}
#[test]
fn keeps_trailing_punctuation_on_request() {
let kept = Doi::with_trailing_punctuation("doi:10.1145/3368089.").unwrap();
assert_eq!(kept.as_str(), "10.1145/3368089.");
assert_eq!(
Doi::with_trailing_punctuation("https://doi.org/10.1145/3368089;")
.unwrap()
.as_str(),
"10.1145/3368089;"
);
assert_eq!(Doi::with_trailing_punctuation("10.1145/3368089"), None);
}
#[test]
fn splits_prefix_and_suffix() {
let d = doi("10.48550/arXiv.1706.03762").unwrap();
assert_eq!(d.prefix(), "10.48550");
assert_eq!(d.suffix(), "arxiv.1706.03762");
}
#[test]
fn rejects_malformed() {
for input in [
"10.1145",
"11.1145/x",
"10.12/x",
"10.abcd/x",
"10.1145/",
"10.1145/a b",
"https://example.org/10.1145/x",
] {
assert!(
matches!(
doi(input),
Err(IdError::Invalid {
kind: IdKind::Doi,
..
})
),
"{input:?} -> {:?}",
doi(input)
);
}
}
}