use std::cmp::Ordering;
use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Attested<T> {
Known(T),
AbsentFromWork,
Unknown,
}
impl<T> Default for Attested<T> {
fn default() -> Self {
Self::Unknown
}
}
impl<T> Attested<T> {
#[must_use]
pub fn known(&self) -> Option<&T> {
match self {
Self::Known(value) => Some(value),
Self::AbsentFromWork | Self::Unknown => None,
}
}
#[must_use]
pub fn is_known(&self) -> bool {
matches!(self, Self::Known(_))
}
#[must_use]
pub fn is_absent_from_work(&self) -> bool {
matches!(self, Self::AbsentFromWork)
}
#[must_use]
pub fn is_unknown(&self) -> bool {
matches!(self, Self::Unknown)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Month {
January,
February,
March,
April,
May,
June,
July,
August,
September,
October,
November,
December,
}
impl Month {
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::January => "January",
Self::February => "February",
Self::March => "March",
Self::April => "April",
Self::May => "May",
Self::June => "June",
Self::July => "July",
Self::August => "August",
Self::September => "September",
Self::October => "October",
Self::November => "November",
Self::December => "December",
}
}
#[must_use]
pub fn number(self) -> u8 {
match self {
Self::January => 1,
Self::February => 2,
Self::March => 3,
Self::April => 4,
Self::May => 5,
Self::June => 6,
Self::July => 7,
Self::August => 8,
Self::September => 9,
Self::October => 10,
Self::November => 11,
Self::December => 12,
}
}
#[must_use]
pub fn from_number(number: u8) -> Option<Self> {
Some(match number {
1 => Self::January,
2 => Self::February,
3 => Self::March,
4 => Self::April,
5 => Self::May,
6 => Self::June,
7 => Self::July,
8 => Self::August,
9 => Self::September,
10 => Self::October,
11 => Self::November,
12 => Self::December,
_ => return None,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "u8", into = "u8")]
pub struct Day(u8);
impl Day {
#[must_use]
pub fn new(day: u8) -> Option<Self> {
(1..=31).contains(&day).then_some(Self(day))
}
#[must_use]
pub fn get(self) -> u8 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("not a day of the month: {0} (expected 1..=31)")]
pub struct NotADay(pub u8);
impl TryFrom<u8> for Day {
type Error = NotADay;
fn try_from(day: u8) -> Result<Self, Self::Error> {
Self::new(day).ok_or(NotADay(day))
}
}
impl From<Day> for u8 {
fn from(day: Day) -> Self {
day.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "i32", into = "i32")]
pub struct Year(i32);
impl Year {
#[must_use]
pub fn new(year: i32) -> Option<Self> {
(year >= 1).then_some(Self(year))
}
#[must_use]
pub fn get(self) -> i32 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("not a year: {0} (expected 1 or later; BCE dates are not modelled)")]
pub struct NotAYear(pub i32);
impl TryFrom<i32> for Year {
type Error = NotAYear;
fn try_from(year: i32) -> Result<Self, Self::Error> {
Self::new(year).ok_or(NotAYear(year))
}
}
impl From<Year> for i32 {
fn from(year: Year) -> Self {
year.0
}
}
impl fmt::Display for Year {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PublicationDate {
Year(Year),
YearMonth {
year: Year,
month: Month,
},
Full {
year: Year,
month: Month,
day: Day,
},
}
fn day_exists(year: Year, month: Month, day: Day) -> bool {
let leap = |y: i32| y % 4 == 0 && (y % 100 != 0 || y % 400 == 0);
let length = match month {
Month::January
| Month::March
| Month::May
| Month::July
| Month::August
| Month::October
| Month::December => 31,
Month::April | Month::June | Month::September | Month::November => 30,
Month::February => {
if leap(year.get()) {
29
} else {
28
}
}
};
day.get() <= length
}
impl PublicationDate {
#[must_use]
pub fn names_a_day_that_exists(self) -> bool {
match self {
Self::Year(_) | Self::YearMonth { .. } => true,
Self::Full { year, month, day } => day_exists(year, month, day),
}
}
#[must_use]
pub fn year(self) -> Year {
match self {
Self::Year(year) | Self::YearMonth { year, .. } | Self::Full { year, .. } => year,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct AccessDate {
pub year: Year,
pub month: Month,
pub day: Day,
}
impl AccessDate {
#[must_use]
pub fn names_a_day_that_exists(self) -> bool {
day_exists(self.year, self.month, self.day)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Stability {
FixedOrArchived,
UnarchivedAndChanging {
retrieved: AccessDate,
},
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct GivenName(String);
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error(
"not a given name: {0:?} (expected letters, optionally hyphen-joined, each part starting with a letter; no whitespace — two names go in two entries; a generational suffix such as `Jr.` has no field on this record)"
)]
pub struct NotAGivenName(pub String);
fn is_given_name_char(c: char) -> bool {
c.is_alphabetic()
|| ('\u{0300}'..='\u{036F}').contains(&c)
|| matches!(c, '.' | '\'' | '\u{2019}')
}
fn is_generational_suffix(name: &str) -> bool {
["jr", "jr.", "sr", "sr."].contains(&name.to_ascii_lowercase().as_str())
|| ["II", "III", "IV"].contains(&name)
}
impl GivenName {
pub fn new(text: &str) -> Result<Self, NotAGivenName> {
let trimmed = text.trim();
let is_a_name = |part: &str| {
part.starts_with(char::is_alphabetic) && part.chars().all(is_given_name_char)
};
let usable = !trimmed.is_empty()
&& trimmed.split('-').all(is_a_name)
&& !is_generational_suffix(trimmed);
if usable {
Ok(Self(trimmed.to_owned()))
} else {
Err(NotAGivenName(text.to_owned()))
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
pub fn parts(&self) -> impl Iterator<Item = &str> {
self.0.split('-')
}
#[must_use]
pub fn initial(&self) -> String {
self.parts()
.map(|part| {
part.chars()
.next()
.map_or_else(String::new, |first| format!("{}.", first.to_uppercase()))
})
.collect::<Vec<_>>()
.join("-")
}
}
impl TryFrom<String> for GivenName {
type Error = NotAGivenName;
fn try_from(text: String) -> Result<Self, Self::Error> {
Self::new(&text)
}
}
impl From<GivenName> for String {
fn from(name: GivenName) -> Self {
name.0
}
}
impl fmt::Display for GivenName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Author {
Person {
surname: String,
given: Vec<GivenName>,
},
Group(String),
}
impl Author {
#[must_use]
pub fn sort_key(&self) -> &str {
match self {
Self::Person { surname, .. } => surname,
Self::Group(name) => name,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct Doi(String);
fn strip_prefix_ignoring_ascii_case<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
text.get(..prefix.len())
.filter(|head| head.eq_ignore_ascii_case(prefix))
.map(|_| &text[prefix.len()..])
}
#[must_use]
pub fn is_printable_identifier(text: &str) -> bool {
!text.is_empty() && text.chars().all(is_uri_char) && percent_escapes_are_well_formed(text)
}
fn percent_escapes_are_well_formed(text: &str) -> bool {
let bytes = text.as_bytes();
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' {
match text.get(index + 1..index + 3) {
Some(hex) if hex.bytes().all(|byte| byte.is_ascii_hexdigit()) => index += 3,
_ => return false,
}
} else {
index += 1;
}
}
true
}
fn is_uri_char(c: char) -> bool {
c.is_ascii_alphanumeric()
|| matches!(
c,
'-' | '.' | '_' | '~'
| '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '='
| ':' | '/' | '?' | '#' | '[' | ']' | '@'
| '%'
)
}
fn is_doi_name_char(c: char) -> bool {
c.is_ascii_graphic() || c.is_alphanumeric()
}
fn percent_decode(text: &str) -> Option<String> {
let bytes = text.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' {
let hex = text.get(index + 1..index + 3)?;
if !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return None;
}
decoded.push(u8::from_str_radix(hex, 16).ok()?);
index += 3;
} else {
decoded.push(bytes[index]);
index += 1;
}
}
String::from_utf8(decoded).ok()
}
const fn is_url_path_safe(byte: u8) -> bool {
byte.is_ascii_alphanumeric()
|| matches!(
byte,
b'-' | b'.' | b'_' | b'~'
| b'!' | b'$' | b'&' | b'\'' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
| b':' | b'@' | b'/'
)
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error(
"not a DOI: {0:?} (expected `10.<digits>/<suffix>`, optionally behind a doi.org resolver URL or a `doi:` prefix)"
)]
pub struct NotADoi(pub String);
impl Doi {
pub fn new(text: &str) -> Result<Self, NotADoi> {
let trimmed = text.trim();
let refused = || NotADoi(text.to_owned());
let bare = match [
"https://doi.org/",
"http://doi.org/",
"https://dx.doi.org/",
"http://dx.doi.org/",
]
.iter()
.find_map(|prefix| strip_prefix_ignoring_ascii_case(trimmed, prefix))
{
Some(encoded) => {
let addressed = encoded.split(['?', '#']).next().unwrap_or_default();
percent_decode(addressed).ok_or_else(refused)?
}
None => strip_prefix_ignoring_ascii_case(trimmed, "doi:")
.unwrap_or(trimmed)
.to_owned(),
};
let well_formed = {
let Some(rest) = bare.strip_prefix("10.") else {
return Err(refused());
};
let Some((registrant, suffix)) = rest.split_once('/') else {
return Err(refused());
};
let registrant_is_numeric = registrant
.split('.')
.all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()));
registrant_is_numeric && !suffix.is_empty() && suffix.chars().all(is_doi_name_char)
};
if well_formed {
Ok(Self(bare))
} else {
Err(refused())
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn url(&self) -> String {
let mut url = String::from("https://doi.org/");
for byte in self.0.bytes() {
if is_url_path_safe(byte) {
url.push(char::from(byte));
} else {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
url.push('%');
url.push(char::from(HEX[usize::from(byte >> 4)]));
url.push(char::from(HEX[usize::from(byte & 0x0F)]));
}
}
url
}
}
impl TryFrom<String> for Doi {
type Error = NotADoi;
fn try_from(text: String) -> Result<Self, Self::Error> {
Self::new(&text)
}
}
impl From<Doi> for String {
fn from(doi: Doi) -> Self {
doi.0
}
}
impl fmt::Display for Doi {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Locator {
Doi(Doi),
Url(String),
Both {
doi: Doi,
url: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum WorkKind {
Document,
Software,
DataSet,
FactSheet,
WebPage,
}
impl WorkKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Document => "document",
Self::Software => "software",
Self::DataSet => "data-set",
Self::FactSheet => "fact-sheet",
Self::WebPage => "web-page",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Reference {
pub id: String,
pub kind: WorkKind,
pub authors: Vec<Author>,
pub published: Attested<PublicationDate>,
pub title: String,
pub version: Attested<String>,
pub descriptor: Attested<String>,
pub publisher: Attested<String>,
pub locator: Attested<Locator>,
pub stability: Stability,
}
impl Reference {
#[must_use]
pub fn new(
id: impl Into<String>,
kind: WorkKind,
title: impl Into<String>,
stability: Stability,
) -> Self {
Self {
id: id.into(),
kind,
authors: Vec::new(),
published: Attested::Unknown,
title: title.into(),
version: Attested::Unknown,
descriptor: Attested::Unknown,
publisher: Attested::Unknown,
locator: Attested::Unknown,
stability,
}
}
#[must_use]
pub fn list_order(a: &Self, b: &Self) -> Ordering {
fn title(reference: &Reference) -> &str {
reference.title.trim()
}
let authors = |r: &Self| -> Vec<(String, Vec<String>)> {
r.authors
.iter()
.map(|author| {
let given = match author {
Author::Person { given, .. } => {
given.iter().map(GivenName::initial).collect()
}
Author::Group(_) => Vec::new(),
};
(author.sort_key().trim().to_owned(), given)
})
.collect()
};
let fold = |name: &str| -> String {
name.chars()
.filter(|c| c.is_alphanumeric())
.flat_map(char::to_lowercase)
.collect()
};
let folded = |names: &[(String, Vec<String>)]| -> Vec<(String, Vec<String>)> {
names
.iter()
.map(|(surname, given)| {
(fold(surname), given.iter().map(|name| fold(name)).collect())
})
.collect()
};
let (left, right) = (authors(a), authors(b));
let date = |r: &Self| match &r.published {
Attested::AbsentFromWork => (0_u8, 0_i32, 0_u8, 0_u8),
Attested::Known(published) => {
let (year, month, day) = match published {
PublicationDate::Year(year) => (year.get(), 0, 0),
PublicationDate::YearMonth { year, month } => (year.get(), month.number(), 0),
PublicationDate::Full { year, month, day } => {
(year.get(), month.number(), day.get())
}
};
(1, year, month, day)
}
Attested::Unknown => (2, 0, 0, 0),
};
folded(&left)
.cmp(&folded(&right))
.then_with(|| left.cmp(&right))
.then_with(|| date(a).cmp(&date(b)))
.then_with(|| fold(title(a)).cmp(&fold(title(b))))
.then_with(|| title(a).cmp(title(b)))
.then_with(|| a.id.cmp(&b.id))
.then_with(|| a.cmp(b))
}
}
#[cfg(test)]
mod tests {
use super::{
AccessDate, Attested, Author, Day, Doi, GivenName, Month, Ordering, PublicationDate,
Reference, Stability, WorkKind, Year, is_printable_identifier,
};
fn year(year: i32) -> Year {
Year::new(year).expect("a valid year")
}
fn given_name(name: &str) -> GivenName {
GivenName::new(name).expect("a valid given name")
}
fn person(surname: &str) -> Author {
Author::Person {
surname: surname.to_owned(),
given: vec![given_name("A")],
}
}
fn dated(id: &str, surname: &str, published: i32) -> Reference {
let mut reference = Reference::new(
id,
WorkKind::Document,
"A title",
Stability::FixedOrArchived,
);
reference.authors = vec![person(surname)];
reference.published = Attested::Known(PublicationDate::Year(year(published)));
reference
}
#[test]
fn a_fresh_record_knows_nothing_it_was_not_told() {
let reference = Reference::new("r", WorkKind::Software, "T", Stability::FixedOrArchived);
assert!(reference.published.is_unknown());
assert!(reference.version.is_unknown());
assert!(reference.descriptor.is_unknown());
assert!(reference.publisher.is_unknown());
assert!(reference.locator.is_unknown());
assert!(reference.authors.is_empty());
}
#[test]
fn the_default_is_the_state_that_refuses() {
let attested: Attested<String> = Attested::default();
assert!(
attested.is_unknown(),
"a field nobody has filled in must default to unresearched, never to absent"
);
}
#[test]
fn absent_and_unknown_are_distinct_on_the_wire() {
let absent: Attested<String> = Attested::AbsentFromWork;
let unknown: Attested<String> = Attested::Unknown;
let absent_json = serde_json::to_string(&absent).expect("serialize");
let unknown_json = serde_json::to_string(&unknown).expect("serialize");
assert_ne!(absent_json, unknown_json);
assert_eq!(absent_json, "\"absent-from-work\"");
assert_eq!(unknown_json, "\"unknown\"");
let back: Attested<String> = serde_json::from_str(&absent_json).expect("deserialize");
assert_eq!(back, Attested::AbsentFromWork);
}
#[test]
fn a_doi_is_stored_bare_however_it_was_written() {
let bare = Doi::new("10.3886/ICPSR36966.v1").expect("bare");
assert_eq!(bare.as_str(), "10.3886/ICPSR36966.v1");
assert_eq!(bare.url(), "https://doi.org/10.3886/ICPSR36966.v1");
for spelling in [
"https://doi.org/10.3886/ICPSR36966.v1",
"http://doi.org/10.3886/ICPSR36966.v1",
"https://dx.doi.org/10.3886/ICPSR36966.v1",
"doi:10.3886/ICPSR36966.v1",
" 10.3886/ICPSR36966.v1 ",
"HTTPS://doi.org/10.3886/ICPSR36966.v1",
"DOI:10.3886/ICPSR36966.v1",
"Doi:10.3886/ICPSR36966.v1",
] {
let parsed = Doi::new(spelling).expect("parses");
assert_eq!(
parsed.url(),
"https://doi.org/10.3886/ICPSR36966.v1",
"the resolver prefix must be applied exactly once, for {spelling:?}"
);
}
}
#[test]
fn a_non_doi_is_refused_rather_than_prefixed() {
for text in [
"",
"https://example.invalid/paper",
"10.no-slash",
"not a doi",
"10./suffix",
"10.foo/suffix",
"10.1037/",
] {
assert!(Doi::new(text).is_err(), "{text:?} is not a DOI");
}
assert!(Doi::new("10.1000.10/123").is_ok());
assert!(Doi::new("10.10.37/x").is_ok());
}
#[test]
fn an_impossible_day_cannot_be_recorded() {
assert!(Day::new(0).is_none());
assert!(Day::new(32).is_none());
assert_eq!(Day::new(1).map(Day::get), Some(1));
assert_eq!(Day::new(31).map(Day::get), Some(31));
let refused: Result<Day, _> = serde_json::from_str("0");
assert!(refused.is_err(), "deserialisation must validate too");
}
#[test]
fn a_date_carries_its_own_precision() {
let day = Day::new(27).expect("valid");
assert_eq!(PublicationDate::Year(year(2026)).year().get(), 2026);
assert_eq!(
PublicationDate::YearMonth {
year: year(2026),
month: Month::August
}
.year()
.get(),
2026
);
assert_eq!(
PublicationDate::Full {
year: year(2026),
month: Month::August,
day
}
.year()
.get(),
2026
);
assert_eq!(Month::August.name(), "August");
assert_eq!(Month::from_number(8), Some(Month::August));
assert_eq!(Month::from_number(0), None);
assert_eq!(Month::from_number(13), None);
assert_eq!(Month::December.number(), 12);
}
#[test]
fn a_retrieval_date_exists_only_where_apa_asks_for_one() {
let changing = Stability::UnarchivedAndChanging {
retrieved: AccessDate {
year: year(2020),
month: Month::January,
day: Day::new(9).expect("valid"),
},
};
match changing {
Stability::UnarchivedAndChanging { retrieved } => {
assert_eq!(retrieved.year.get(), 2020);
assert_eq!(retrieved.month.name(), "January");
assert_eq!(retrieved.day.get(), 9);
}
Stability::FixedOrArchived => unreachable!("constructed as changing"),
}
}
#[test]
fn a_group_author_sorts_on_its_whole_name() {
let group = Author::Group("World Health Organization".to_owned());
assert_eq!(group.sort_key(), "World Health Organization");
assert_eq!(person("Salas").sort_key(), "Salas");
}
#[test]
fn a_list_is_ordered_alphabetically_and_totally() {
let mut refs = [
dated("c", "salas", 2019),
dated("a", "Zhang", 1999),
dated("b", "Salas", 2020),
dated("d", "Abbott", 2020),
];
refs.sort_by(Reference::list_order);
let order: Vec<&str> = refs.iter().map(|r| r.id.as_str()).collect();
assert_eq!(order, ["d", "b", "c", "a"]);
}
#[test]
fn an_undated_work_sorts_before_the_same_authors_dated_ones() {
let mut undated = dated("u", "Salas", 1999);
undated.published = Attested::AbsentFromWork;
let mut unresearched = dated("x", "Salas", 1999);
unresearched.published = Attested::Unknown;
let mut refs = [dated("b", "Salas", 2020), unresearched, undated];
refs.sort_by(Reference::list_order);
let order: Vec<&str> = refs.iter().map(|r| r.id.as_str()).collect();
assert_eq!(
order,
["u", "b", "x"],
"n.d. first, then years; a date nobody looked up is not a date and sorts last"
);
}
#[test]
fn a_given_name_that_cannot_be_rendered_cannot_be_written_down() {
for refused in ["", " ", "Jean--Paul", "-Paul", "Jean-", "-", " - "] {
assert!(
GivenName::new(refused).is_err(),
"{refused:?} has a part with no initial in it, so it must not be recordable"
);
}
for suffix in ["Jr", "Jr.", "jr.", "SR", "Sr.", "II", "III", "IV"] {
assert!(
GivenName::new(suffix).is_err(),
"{suffix:?} is a generational suffix, and this record has no field for one"
);
}
for accepted in [
"Mary",
"M.",
"Jean-Paul",
"Ibáñez",
"N'Golo",
"Iva",
"Владимир",
] {
assert!(GivenName::new(accepted).is_ok(), "{accepted:?}");
}
assert_eq!(given_name(" Mary ").as_str(), "Mary");
assert!(GivenName::new("Jean - Paul").is_err());
assert!(serde_json::from_str::<GivenName>("\"Jean--Paul\"").is_err());
assert!(serde_json::from_str::<GivenName>("\"\"").is_err());
assert_eq!(
serde_json::from_str::<GivenName>("\"Mary\"").expect("valid"),
given_name("Mary")
);
}
#[test]
fn an_initial_is_derived_in_one_place_so_two_readers_cannot_disagree() {
for (recorded, initial) in [
("Mary", "M."),
("M.", "M."),
("Jean-Paul", "J.-P."),
("ibáñez", "I."),
] {
assert_eq!(given_name(recorded).initial(), initial, "{recorded:?}");
}
}
#[test]
fn two_authors_the_page_prints_alike_are_not_ordered_by_what_it_hides() {
let mut spelled_out = dated("spelled-out", "Smith", 2020);
spelled_out.authors = vec![Author::Person {
surname: "Smith".to_owned(),
given: vec![given_name("Mary")],
}];
spelled_out.title = "A title".to_owned();
let mut initialled = dated("initialled", "Smith", 2020);
initialled.authors = vec![Author::Person {
surname: "Smith".to_owned(),
given: vec![given_name("M.")],
}];
initialled.title = "B title".to_owned();
let mut refs = [initialled, spelled_out];
refs.sort_by(Reference::list_order);
assert_eq!(
refs.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
["spelled-out", "initialled"],
"the title decides, because it is the only difference the page shows"
);
let mut anne = dated("anne", "Smith", 2020);
anne.authors = vec![Author::Person {
surname: "Smith".to_owned(),
given: vec![given_name("Anne")],
}];
let mut tom = dated("tom", "Smith", 1990);
tom.authors = vec![Author::Person {
surname: "Smith".to_owned(),
given: vec![given_name("Tom")],
}];
let mut people = [tom, anne];
people.sort_by(Reference::list_order);
assert_eq!(
people.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
["anne", "tom"]
);
}
#[test]
fn a_year_that_is_not_a_year_cannot_be_written_down() {
for refused in [0, -1, -400, i32::MIN] {
assert!(Year::new(refused).is_none(), "{refused}");
assert!(
serde_json::from_str::<Year>(&refused.to_string()).is_err(),
"deserialisation must validate too: {refused}"
);
}
for accepted in [1, 1899, 2026, i32::MAX] {
assert_eq!(Year::new(accepted).expect("a valid year").get(), accepted);
}
assert!(Year::new(2999).is_some());
}
#[test]
fn an_identifier_cannot_carry_a_character_a_reader_cannot_see() {
for hidden in [
"10.1234/foo\nbar",
"10.1234/foo bar",
"10.1234/a\tb",
"10.1234/foo\u{7}bar",
"10.1234/foo\u{202e}bar",
"10.1234/foo\u{200b}bar",
"10.1234/foo\u{feff}bar",
] {
assert!(
Doi::new(hidden).is_err(),
"{hidden:?} would become a link that is not what it looks like"
);
assert!(
serde_json::from_str::<Doi>(&serde_json::to_string(hidden).expect("json")).is_err(),
"deserialisation must validate too: {hidden:?}"
);
}
for fine in [
"10.3886/ICPSR36966.v1",
"10.1037/abc123",
"10.1000.10/123",
"10.1234/a(b)c;d",
] {
assert!(Doi::new(fine).is_ok(), "{fine:?}");
}
assert!(is_printable_identifier("https://example.org/a-b"));
assert!(!is_printable_identifier("https://example.org/a b"));
}
fn interesting_code_points() -> impl Iterator<Item = char> {
[
0x0000..=0x00FF_u32,
0x0300..=0x036F,
0x0590..=0x0620,
0x2000..=0x2070,
0x3000..=0x3002,
0xFEFF..=0xFEFF,
0xFFF9..=0xFFFC,
0xE0000..=0xE0080,
]
.into_iter()
.flatten()
.filter_map(char::from_u32)
}
#[test]
fn a_given_name_is_accepted_whole_or_not_at_all() {
let mut accepted = 0_u32;
for c in interesting_code_points() {
let candidate = format!("Ma{c}ry");
let Ok(name) = GivenName::new(&candidate) else {
continue;
};
accepted += 1;
assert_eq!(
name.as_str(),
candidate,
"U+{:04X} was accepted, so it must be stored exactly",
c as u32
);
assert_eq!(
name.initial().matches('.').count(),
candidate.split('-').count(),
"U+{:04X}: {candidate:?} rendered {:?}, losing a part",
c as u32,
name.initial()
);
assert!(
!candidate.chars().any(char::is_whitespace),
"U+{:04X} is whitespace and must not be inside one name",
c as u32
);
let joined = format!("Ma{c}ry-Jo");
let hyphenated = GivenName::new(&joined).expect("a valid given name");
assert_eq!(
hyphenated.initial().matches('.').count(),
joined.split('-').count(),
"U+{:04X}: {joined:?} rendered {:?}, losing a part",
c as u32,
hyphenated.initial()
);
}
assert!(
accepted > 100,
"the sweep accepted only {accepted} code points — the allowlist cannot be that narrow"
);
}
#[test]
fn a_doi_is_accepted_whole_or_not_at_all() {
let mut accepted = 0_u32;
for c in interesting_code_points() {
let candidate = format!("10.1234/a{c}b");
let Ok(doi) = Doi::new(&candidate) else {
continue;
};
accepted += 1;
assert_eq!(doi.as_str(), "10.1234/a{c}b".replace("{c}", &c.to_string()));
assert!(
doi.as_str()
.chars()
.all(|c| c.is_ascii_graphic() || c.is_alphanumeric()),
"U+{:04X} was accepted into a DOI but cannot be printed",
c as u32
);
let url = doi.url();
let rest = url
.strip_prefix("https://doi.org/")
.expect("the resolver prefix");
assert!(
rest.chars().all(|c| c.is_ascii_graphic()),
"U+{:04X} left something unprintable in {url:?}",
c as u32
);
for reserved in ['#', '?'] {
assert!(
!rest.contains(reserved),
"U+{:04X} left a bare {reserved:?} in {url:?}, which the resolver never receives",
c as u32
);
}
assert_eq!(
decode_escapes_independently(rest),
doi.as_str(),
"U+{:04X}: the URL must decode back to the recorded DOI",
c as u32
);
assert_eq!(
Doi::new(&url).expect("a URL this type produced"),
doi,
"U+{:04X}: {url:?} did not parse back to the DOI it renders",
c as u32
);
}
assert!(
accepted > 50,
"the sweep accepted only {accepted} code points — too narrow to be testing an allowlist"
);
}
fn decode_escapes_independently(text: &str) -> String {
let mut bytes = Vec::new();
let mut rest = text.as_bytes();
while let Some((first, tail)) = rest.split_first() {
if *first == b'%' && tail.len() >= 2 {
let hex = std::str::from_utf8(&tail[..2]).expect("ascii hex");
bytes.push(u8::from_str_radix(hex, 16).expect("valid escape"));
rest = &tail[2..];
} else {
bytes.push(*first);
rest = tail;
}
}
String::from_utf8(bytes).expect("valid utf-8")
}
#[test]
fn a_doi_url_names_the_record_that_was_recorded() {
for (recorded, expected) in [
("10.1234/a#b", "https://doi.org/10.1234/a%23b"),
("10.1234/a?b", "https://doi.org/10.1234/a%3Fb"),
("10.1234/a%b", "https://doi.org/10.1234/a%25b"),
("10.1234/a\"b", "https://doi.org/10.1234/a%22b"),
] {
let doi = Doi::new(recorded).expect("a printable DOI");
assert_eq!(doi.as_str(), recorded, "the record keeps what was written");
assert_eq!(doi.url(), expected);
}
let wiley = Doi::new("10.1002/(SICI)1097-0258(19970815)16:15<1707::AID-SIM605>3.0.CO;2-Y")
.expect("a real DOI");
assert_eq!(
wiley.url(),
"https://doi.org/10.1002/(SICI)1097-0258(19970815)16:15%3C1707::AID-SIM605%3E3.0.CO;2-Y"
);
assert_eq!(
Doi::new("10.3886/ICPSR36966.v1").expect("valid").url(),
"https://doi.org/10.3886/ICPSR36966.v1"
);
}
#[test]
fn what_is_stored_is_the_identifier_and_never_its_url_form() {
let from_url = Doi::new("https://doi.org/10.1234/a%23b").expect("a resolver URL");
let from_bare = Doi::new("doi:10.1234/a#b").expect("the identifier");
assert_eq!(from_url, from_bare, "one DOI, two ways of writing it down");
assert_eq!(from_url.as_str(), "10.1234/a#b", "stored raw, not encoded");
assert_eq!(from_url.url(), "https://doi.org/10.1234/a%23b");
for recorded in [
"10.1234/a#b",
"10.1234/a%b",
"10.1234/a?b",
"10.1234/plain",
"10.1234/ünïcode",
] {
let doi = Doi::new(recorded).expect("a DOI");
assert_eq!(doi.as_str(), recorded);
assert_eq!(
Doi::new(&doi.url()).expect("its own URL"),
doi,
"{recorded:?} did not survive a render-and-reparse"
);
}
assert_ne!(
Doi::new("10.1234/a%23b").expect("a literal percent"),
Doi::new("10.1234/a#b").expect("a literal hash")
);
for broken in [
"https://doi.org/10.1234/a%2",
"https://doi.org/10.1234/a%zzb",
"https://doi.org/10.1234/a%",
] {
assert!(Doi::new(broken).is_err(), "{broken:?}");
}
}
#[test]
fn a_non_ascii_doi_is_recordable_as_itself() {
for recorded in ["10.1234/中文", "10.1234/ünïcode", "10.1234/абв"] {
let doi = Doi::new(recorded).expect("a legal DOI name");
assert_eq!(doi.as_str(), recorded, "recorded as the identifier itself");
let url = doi.url();
assert!(
url.chars().all(|c| c.is_ascii_graphic()),
"the rendered link is always ASCII: {url:?}"
);
assert_eq!(Doi::new(&url).expect("its own URL"), doi);
}
for hidden in ["10.1234/a\u{200b}b", "10.1234/a\u{061c}b", "10.1234/a b"] {
assert!(Doi::new(hidden).is_err(), "{hidden:?}");
}
}
#[test]
fn every_public_type_here_is_reachable_from_the_crate_root() {
fn assert_reachable<T>() {}
assert_reachable::<crate::Attested<String>>();
assert_reachable::<crate::Month>();
assert_reachable::<crate::Day>();
assert_reachable::<crate::NotADay>();
assert_reachable::<crate::Year>();
assert_reachable::<crate::NotAYear>();
assert_reachable::<crate::PublicationDate>();
assert_reachable::<crate::AccessDate>();
assert_reachable::<crate::Stability>();
assert_reachable::<crate::GivenName>();
assert_reachable::<crate::NotAGivenName>();
assert_reachable::<crate::Author>();
assert_reachable::<crate::Doi>();
assert_reachable::<crate::NotADoi>();
assert_reachable::<crate::Locator>();
assert_reachable::<crate::WorkKind>();
assert_reachable::<crate::Reference>();
assert!(crate::is_printable_identifier("https://example.invalid/ok"));
let published = crate::PublicationDate::Year(crate::Year::new(2020).expect("a year"));
let author = crate::Author::Person {
surname: "Luna".to_owned(),
given: vec![crate::GivenName::new("R").expect("a given name")],
};
assert_eq!(published.year().get(), 2020);
assert_eq!(author.sort_key(), "Luna");
}
#[test]
fn a_locator_cannot_carry_a_character_that_escapes_an_attribute() {
for escaping in [
"https://example.invalid/a\"onmouseover=\"alert(1)",
"https://example.invalid/a\"",
"https://example.invalid/a<script>",
"https://example.invalid/a>b",
"https://example.invalid/a\\b",
"https://example.invalid/a{b}",
"https://example.invalid/a^b",
"https://example.invalid/a|b",
"https://example.invalid/a`b",
] {
assert!(
!is_printable_identifier(escaping),
"{escaping:?} can escape a quoted attribute and must not be a link target"
);
}
for ordinary in [
"https://example.invalid/a?b=c&d=e#f",
"https://example.invalid/~user/a_b-c.d",
"https://example.invalid/a'b",
"https://example.invalid/(a)+b,c;d=e!f$g*h",
"https://example.invalid/a%20b",
"https://[::1]:8080/x",
] {
assert!(is_printable_identifier(ordinary), "{ordinary:?}");
}
}
#[test]
fn a_percent_that_introduces_nothing_is_not_a_uri() {
for malformed in [
"https://example.invalid/a%ZZ",
"https://example.invalid/a%",
"https://example.invalid/a%2",
"https://example.invalid/%",
"https://example.invalid/a%g0b",
] {
assert!(
!is_printable_identifier(malformed),
"{malformed:?} carries a `%` that introduces no escape"
);
}
for fine in [
"https://example.invalid/a%20b",
"https://example.invalid/100%25",
"https://example.invalid/a%2Fb",
"https://example.invalid/a%2fb",
] {
assert!(is_printable_identifier(fine), "{fine:?}");
}
for recorded in [
"10.1234/a#b",
"10.1234/a%b",
"10.1234/中文",
"10.1234/plain",
] {
let url = Doi::new(recorded).expect("a DOI").url();
assert!(is_printable_identifier(&url), "{url:?}");
}
}
#[test]
fn a_resolver_url_ends_at_a_query_or_a_fragment() {
for (url, named) in [
("https://doi.org/10.1234/a#b", "10.1234/a"),
("https://doi.org/10.1234/a?b", "10.1234/a"),
("https://doi.org/10.1234/a#b?c", "10.1234/a"),
("https://doi.org/10.1234/a%23b", "10.1234/a#b"),
("https://doi.org/10.1234/plain", "10.1234/plain"),
] {
assert_eq!(
Doi::new(url).expect("a resolver URL").as_str(),
named,
"{url:?}"
);
}
assert_ne!(
Doi::new("10.1234/a#b").expect("a bare name"),
Doi::new("https://doi.org/10.1234/a#b").expect("a URL")
);
for recorded in ["10.1234/a#b", "10.1234/a?b", "10.1234/a%b"] {
let doi = Doi::new(recorded).expect("a DOI");
assert_eq!(
Doi::new(&doi.url()).expect("its own URL"),
doi,
"{recorded:?} did not survive render-and-reparse as a URL"
);
}
}
#[test]
fn a_registrant_code_is_not_held_to_a_length_convention() {
for short in ["10.1/x", "10.12/x", "10.123/x"] {
assert!(Doi::new(short).is_ok(), "{short:?}");
}
assert!(Doi::new("10.1000/182").is_ok(), "the DOI Foundation's own");
for malformed in ["10./x", "10.a/x", "10.1./x", "11.1234/x", "10.1234"] {
assert!(Doi::new(malformed).is_err(), "{malformed:?}");
}
}
#[test]
fn a_date_that_did_not_happen_is_not_a_date() {
let day = |d: u8| Day::new(d).expect("a day");
for (y, m, d) in [
(2021, Month::February, 31),
(2021, Month::February, 30),
(2021, Month::February, 29),
(2021, Month::April, 31),
(2021, Month::June, 31),
(2021, Month::September, 31),
(2021, Month::November, 31),
] {
let date = PublicationDate::Full {
year: year(y),
month: m,
day: day(d),
};
assert!(!date.names_a_day_that_exists(), "{m:?} {d}, {y}");
assert!(
!AccessDate {
year: year(y),
month: m,
day: day(d)
}
.names_a_day_that_exists(),
"a retrieval date shares the calendar: {m:?} {d}, {y}"
);
}
for (y, exists) in [(2020, true), (2021, false), (2000, true), (1900, false)] {
let date = PublicationDate::Full {
year: year(y),
month: Month::February,
day: day(29),
};
assert_eq!(date.names_a_day_that_exists(), exists, "29 February {y}");
}
assert!(
PublicationDate::Full {
year: year(2021),
month: Month::January,
day: day(31)
}
.names_a_day_that_exists()
);
assert!(PublicationDate::Year(year(2021)).names_a_day_that_exists());
assert!(
PublicationDate::YearMonth {
year: year(2021),
month: Month::February
}
.names_a_day_that_exists()
);
}
#[test]
fn apa_alphabetises_letter_by_letter_ignoring_punctuation() {
let named = |id: &str, surname: &str| {
let mut reference = dated(id, surname, 2020);
reference.authors = vec![Author::Person {
surname: surname.to_owned(),
given: vec![given_name("A")],
}];
reference
};
let mut refs = [
named("oneil", "O'Neil"),
named("olsen", "Olsen"),
named("omalley", "O'Malley"),
];
refs.sort_by(Reference::list_order);
assert_eq!(
refs.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
["olsen", "omalley", "oneil"]
);
let mut ties = [named("with", "O'Neil"), named("without", "ONeil")];
ties.sort_by(Reference::list_order);
let order: Vec<&str> = ties.iter().map(|r| r.id.as_str()).collect();
assert_eq!(order.len(), 2);
assert_ne!(
Reference::list_order(&ties[0], &ties[1]),
Ordering::Equal,
"the fold must not collapse two distinct names into one position"
);
}
#[test]
fn a_kinds_wire_token_is_the_one_it_documents() {
for kind in [
WorkKind::Document,
WorkKind::Software,
WorkKind::DataSet,
WorkKind::FactSheet,
WorkKind::WebPage,
] {
let json = serde_json::to_string(&kind).expect("serialize");
assert_eq!(
json,
format!("\"{}\"", kind.as_str()),
"the wire token must be the documented one"
);
}
}
#[test]
fn one_author_precedes_the_same_authors_collaborations() {
let mut solo = dated("solo", "Salas", 2020);
solo.authors = vec![person("Salas")];
let mut with_zhang = dated("with-zhang", "Salas", 1990);
with_zhang.authors = vec![person("Salas"), person("Zhang")];
let mut with_abbott = dated("with-abbott", "Salas", 1999);
with_abbott.authors = vec![person("Salas"), person("Abbott")];
let mut refs = [with_zhang, solo, with_abbott];
refs.sort_by(Reference::list_order);
let order: Vec<&str> = refs.iter().map(|r| r.id.as_str()).collect();
assert_eq!(
order,
["solo", "with-abbott", "with-zhang"],
"the solo work first despite its later year, then by second author"
);
}
#[test]
fn two_works_from_one_year_are_separated_by_month_not_by_title() {
let mut december = dated("december", "Salas", 2020);
december.title = "A title".to_owned();
december.published = Attested::Known(PublicationDate::YearMonth {
year: year(2020),
month: Month::December,
});
let mut january = dated("january", "Salas", 2020);
january.title = "Z title".to_owned();
january.published = Attested::Known(PublicationDate::Full {
year: year(2020),
month: Month::January,
day: Day::new(9).expect("valid"),
});
let mut refs = [december, january];
refs.sort_by(Reference::list_order);
let order: Vec<&str> = refs.iter().map(|r| r.id.as_str()).collect();
assert_eq!(order, ["january", "december"]);
}
#[test]
fn two_people_with_one_surname_are_ordered_by_their_given_names() {
let mut anne = dated("anne", "Smith", 2020);
anne.authors = vec![Author::Person {
surname: "Smith".to_owned(),
given: vec![given_name("Anne")],
}];
let mut tom = dated("tom", "Smith", 1990);
tom.authors = vec![Author::Person {
surname: "Smith".to_owned(),
given: vec![given_name("Tom")],
}];
let mut refs = [tom, anne];
refs.sort_by(Reference::list_order);
assert_eq!(
refs.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
["anne", "tom"]
);
}
#[test]
fn whitespace_nobody_can_see_does_not_decide_the_order() {
let padded = {
let mut r = dated("padded", " Salas ", 2020);
r.title = " A title ".to_owned();
r
};
let tidy = dated("padded", "Salas", 2020);
assert_eq!(
Reference::list_order(&padded, &tidy),
Reference::list_order(&tidy, &padded).reverse(),
"the comparison is symmetric"
);
let mut zhang = dated("zhang", "Zhang", 2020);
zhang.title = "A title".to_owned();
let mut refs = [zhang, padded];
refs.sort_by(Reference::list_order);
assert_eq!(
refs.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
["padded", "zhang"],
"a padded `Salas` still sorts before `Zhang`"
);
}
#[test]
fn records_alike_in_every_key_still_have_a_defined_order() {
let mut one = dated("same-id", "Salas", 2020);
one.publisher = Attested::Known("A Publisher".to_owned());
let mut two = dated("same-id", "Salas", 2020);
two.publisher = Attested::Known("B Publisher".to_owned());
assert_eq!(Reference::list_order(&one, &two), Ordering::Less);
let mut forwards = [one.clone(), two.clone()];
forwards.sort_by(Reference::list_order);
let mut backwards = [two, one];
backwards.sort_by(Reference::list_order);
assert_eq!(
forwards, backwards,
"the same pair in either order must sort the same way"
);
}
#[test]
fn ordering_is_stable_whatever_order_the_input_arrives_in() {
let build = || {
vec![
dated("a", "Zhang", 1999),
dated("b", "Salas", 2020),
dated("d", "Abbott", 2020),
]
};
let mut forwards = build();
forwards.sort_by(Reference::list_order);
let mut backwards = build();
backwards.reverse();
backwards.sort_by(Reference::list_order);
assert_eq!(forwards, backwards);
}
}