use std::collections::BTreeMap;
use std::fmt;
use rto_graph::reference::{
AccessDate, Attested, Author, GivenName, Locator, PublicationDate, Reference, Stability,
WorkKind, is_printable_identifier,
};
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum EntrySpan {
Plain(String),
Italic(String),
Link {
text: String,
href: String,
},
}
impl EntrySpan {
#[must_use]
pub fn text(&self) -> &str {
match self {
Self::Plain(text) | Self::Italic(text) | Self::Link { text, .. } => text,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Entry {
pub reference_id: String,
pub spans: Vec<EntrySpan>,
}
impl Entry {
#[must_use]
pub fn plain_text(&self) -> String {
self.spans.iter().map(EntrySpan::text).collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Missing {
Identifier,
Author,
AuthorInitials {
position: usize,
},
PublicationDate,
Title,
RetrievalDate,
Version,
Descriptor,
Publisher,
Locator,
Source,
}
impl Missing {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Identifier => "identifier",
Self::Author => "author",
Self::AuthorInitials { .. } => "author-initials",
Self::PublicationDate => "publication-date",
Self::Title => "title",
Self::RetrievalDate => "retrieval-date",
Self::Version => "version",
Self::Descriptor => "descriptor",
Self::Publisher => "publisher",
Self::Locator => "locator",
Self::Source => "source",
}
}
}
impl fmt::Display for Missing {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::AuthorInitials { position } => write!(f, "author-initials (author {position})"),
other => f.write_str(other.as_str()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Refusal {
pub reference_id: String,
pub missing: Vec<Missing>,
}
impl fmt::Display for Refusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "cannot cite {}: missing ", self.reference_id)?;
for (index, missing) in self.missing.iter().enumerate() {
if index > 0 {
f.write_str(", ")?;
}
write!(f, "{missing}")?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum CitationForm {
Parenthetical,
Narrative,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Ambiguity {
pub citation: String,
pub reference_ids: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ReferenceList {
pub entries: Vec<Entry>,
pub refused: Vec<Refusal>,
pub duplicate_ids: Vec<String>,
pub ambiguous: Vec<Ambiguity>,
}
fn requires_descriptor(kind: WorkKind) -> bool {
match kind {
WorkKind::Software | WorkKind::DataSet | WorkKind::FactSheet => true,
WorkKind::Document | WorkKind::WebPage => false,
}
}
fn requires_locator(kind: WorkKind) -> bool {
match kind {
WorkKind::WebPage => true,
WorkKind::Document | WorkKind::Software | WorkKind::DataSet | WorkKind::FactSheet => false,
}
}
fn title_is_italic(kind: WorkKind) -> bool {
match kind {
WorkKind::Document
| WorkKind::Software
| WorkKind::DataSet
| WorkKind::FactSheet
| WorkKind::WebPage => true,
}
}
fn starts_with_word(text: &str, word: &str) -> bool {
text.get(..word.len())
.is_some_and(|head| head.eq_ignore_ascii_case(word))
&& text[word.len()..]
.chars()
.next()
.is_none_or(|next| !next.is_alphanumeric())
}
fn validate(reference: &Reference) -> Vec<Missing> {
let mut missing = Vec::new();
if reference.id.trim().is_empty() {
missing.push(Missing::Identifier);
}
let nameless = reference
.authors
.iter()
.any(|author| author.sort_key().trim().is_empty());
if reference.authors.is_empty() || nameless {
missing.push(Missing::Author);
}
for (index, author) in reference.authors.iter().enumerate() {
if let Author::Person { surname, given } = author
&& !surname.trim().is_empty()
&& initials(given).is_none()
{
missing.push(Missing::AuthorInitials {
position: index + 1,
});
}
}
let impossible_date = reference
.published
.known()
.is_some_and(|published| !published.names_a_day_that_exists());
if reference.published.is_unknown() || impossible_date {
missing.push(Missing::PublicationDate);
}
if reference.title.trim().is_empty() {
missing.push(Missing::Title);
}
let version = attested_text(&reference.version);
let version_repeats_its_label = version
.value()
.is_some_and(|text| starts_with_word(text, "version"));
if !version.is_recorded() || version_repeats_its_label {
missing.push(Missing::Version);
}
let descriptor = attested_text(&reference.descriptor);
let descriptor_brings_its_own_brackets = descriptor
.value()
.is_some_and(|text| text.contains('[') || text.contains(']'));
if requires_descriptor(reference.kind) {
if descriptor.value().is_none() || descriptor_brings_its_own_brackets {
missing.push(Missing::Descriptor);
}
} else if !descriptor.is_recorded() || descriptor_brings_its_own_brackets {
missing.push(Missing::Descriptor);
}
let publisher = attested_text(&reference.publisher);
if !publisher.is_recorded() {
missing.push(Missing::Publisher);
}
let locator = printable_locator(reference);
let locator_required = requires_locator(reference.kind)
|| matches!(reference.stability, Stability::UnarchivedAndChanging { .. });
let locator_missing = match &reference.locator {
Attested::Unknown => true,
Attested::Known(_) => locator.is_none(),
Attested::AbsentFromWork => locator_required,
};
if locator_missing {
missing.push(Missing::Locator);
}
if let Stability::UnarchivedAndChanging { retrieved } = reference.stability
&& !retrieved.names_a_day_that_exists()
{
missing.push(Missing::RetrievalDate);
}
if reference.publisher.is_absent_from_work() && reference.locator.is_absent_from_work() {
missing.push(Missing::Source);
}
missing
}
enum Recorded<'a> {
Value(&'a str),
AbsentFromWork,
Unrecorded,
}
impl<'a> Recorded<'a> {
fn is_recorded(&self) -> bool {
!matches!(self, Self::Unrecorded)
}
fn value(&self) -> Option<&'a str> {
match self {
Self::Value(text) => Some(text),
Self::AbsentFromWork | Self::Unrecorded => None,
}
}
}
fn attested_text(field: &Attested<String>) -> Recorded<'_> {
match field {
Attested::AbsentFromWork => Recorded::AbsentFromWork,
Attested::Unknown => Recorded::Unrecorded,
Attested::Known(text) => match text.trim() {
"" => Recorded::Unrecorded,
text => Recorded::Value(text),
},
}
}
fn printable_locator(reference: &Reference) -> Option<String> {
match reference.locator.known()? {
Locator::Doi(doi) | Locator::Both { doi, .. } => Some(doi.url()),
Locator::Url(url) => is_web_url(url).then(|| url.clone()),
}
}
fn initials(given: &[GivenName]) -> Option<String> {
(!given.is_empty()).then(|| {
given
.iter()
.map(GivenName::initial)
.collect::<Vec<_>>()
.join(" ")
})
}
fn author_name(author: &Author) -> Option<String> {
match author {
Author::Person { surname, given } => {
Some(format!("{}, {}", surname.trim(), initials(given)?))
}
Author::Group(name) => Some(name.trim().to_owned()),
}
}
const LISTED_BEFORE_ELLIPSIS: usize = 19;
const MAX_LISTED_IN_FULL: usize = 20;
fn author_element(authors: &[Author]) -> Option<String> {
let names: Vec<String> = authors.iter().map(author_name).collect::<Option<_>>()?;
let (last, rest) = names.split_last()?;
Some(match names.len() {
1 => last.clone(),
2..=MAX_LISTED_IN_FULL => format!("{}, & {last}", rest.join(", ")),
_ => format!(
"{}, . . . {last}",
rest[..LISTED_BEFORE_ELLIPSIS].join(", ")
),
})
}
fn reference_date(published: &Attested<PublicationDate>) -> Option<String> {
Some(match published {
Attested::AbsentFromWork => "n.d.".to_owned(),
Attested::Unknown => return None,
Attested::Known(PublicationDate::Year(year)) => year.to_string(),
Attested::Known(PublicationDate::YearMonth { year, month }) => {
format!("{year}, {}", month.name())
}
Attested::Known(PublicationDate::Full { year, month, day }) => {
format!("{year}, {} {}", month.name(), day.get())
}
})
}
fn retrieval_clause(retrieved: AccessDate) -> String {
format!(
"Retrieved {} {}, {}, from ",
retrieved.month.name(),
retrieved.day.get(),
retrieved.year
)
}
fn is_web_url(url: &str) -> bool {
let authority = ["https://", "http://"].iter().find_map(|scheme| {
url.get(..scheme.len())
.filter(|head| head.eq_ignore_ascii_case(scheme))
.map(|_| &url[scheme.len()..])
});
let Some(authority) = authority else {
return false;
};
let authority = authority.split(['/', '?', '#']).next().unwrap_or_default();
let after_userinfo = authority.rsplit('@').next().unwrap_or_default();
let host = match after_userinfo.strip_prefix('[') {
Some(literal) => match literal.split_once(']') {
Some((address, _port)) => address,
None => "",
},
None => after_userinfo.split(':').next().unwrap_or_default(),
};
!host.is_empty() && is_printable_identifier(url)
}
fn publisher_repeats_the_author(reference: &Reference, publisher: &str) -> bool {
match reference.authors.as_slice() {
[Author::Group(name)] => name.trim() == publisher.trim(),
_ => false,
}
}
fn capitalise_first(text: &str) -> String {
let mut chars = text.chars();
chars.next().map_or_else(String::new, |first| {
first.to_uppercase().chain(chars).collect()
})
}
fn needs_period(text: &str) -> bool {
!matches!(text.chars().last(), Some('.' | '?' | '!'))
}
pub fn entry(reference: &Reference) -> Result<Entry, Refusal> {
let missing = validate(reference);
if !missing.is_empty() {
return Err(Refusal {
reference_id: reference.id.clone(),
missing,
});
}
let refuse = |missing: Missing| Refusal {
reference_id: reference.id.clone(),
missing: vec![missing],
};
let authors = author_element(&reference.authors).ok_or_else(|| refuse(Missing::Author))?;
let date =
reference_date(&reference.published).ok_or_else(|| refuse(Missing::PublicationDate))?;
let mut spans = Vec::new();
let separator = if needs_period(&authors) { "." } else { "" };
spans.push(EntrySpan::Plain(format!("{authors}{separator} ({date}). ")));
let title = reference.title.trim().to_owned();
let mut tail_of_title = title.clone();
spans.push(if title_is_italic(reference.kind) {
EntrySpan::Italic(title)
} else {
EntrySpan::Plain(title)
});
if let Some(version) = attested_text(&reference.version).value() {
let text = format!(" (Version {version})");
tail_of_title.clone_from(&text);
spans.push(EntrySpan::Plain(text));
}
if let Some(descriptor) = attested_text(&reference.descriptor).value() {
let text = format!(" [{}]", capitalise_first(descriptor));
tail_of_title.clone_from(&text);
spans.push(EntrySpan::Plain(text));
}
let mut tail = String::new();
if needs_period(&tail_of_title) {
tail.push('.');
}
let locator = printable_locator(reference);
let publisher = attested_text(&reference.publisher)
.value()
.filter(|publisher| !publisher_repeats_the_author(reference, publisher));
if let Some(publisher) = publisher {
tail.push(' ');
tail.push_str(publisher);
if needs_period(publisher) {
tail.push('.');
}
}
if locator.is_some() {
tail.push(' ');
if let Stability::UnarchivedAndChanging { retrieved } = reference.stability {
tail.push_str(&retrieval_clause(retrieved));
}
}
spans.push(EntrySpan::Plain(tail));
if let Some(url) = locator {
spans.push(EntrySpan::Link {
text: url.clone(),
href: url,
});
}
Ok(Entry {
reference_id: reference.id.clone(),
spans,
})
}
pub fn in_text(reference: &Reference, form: CitationForm) -> Result<Entry, Refusal> {
let missing = validate(reference);
if !missing.is_empty() {
return Err(Refusal {
reference_id: reference.id.clone(),
missing,
});
}
let refuse = |missing: Missing| Refusal {
reference_id: reference.id.clone(),
missing: vec![missing],
};
let year = match &reference.published {
Attested::AbsentFromWork => "n.d.".to_owned(),
Attested::Known(published) => published.year().to_string(),
Attested::Unknown => return Err(refuse(Missing::PublicationDate)),
};
let label = |author: &Author| match author {
Author::Person { surname, .. } => surname.trim().to_owned(),
Author::Group(name) => name.trim().to_owned(),
};
let first = reference
.authors
.first()
.map(&label)
.ok_or_else(|| refuse(Missing::Author))?;
let authors = match reference.authors.len() {
0 | 1 => first,
2 => {
let second = reference
.authors
.get(1)
.map(&label)
.ok_or_else(|| refuse(Missing::Author))?;
match form {
CitationForm::Parenthetical => format!("{first} & {second}"),
CitationForm::Narrative => format!("{first} and {second}"),
}
}
_ => format!("{first} et al."),
};
let text = match form {
CitationForm::Parenthetical => format!("({authors}, {year})"),
CitationForm::Narrative => format!("{authors} ({year})"),
};
Ok(Entry {
reference_id: reference.id.clone(),
spans: vec![EntrySpan::Plain(text)],
})
}
#[must_use]
pub fn reference_list(references: &[Reference]) -> ReferenceList {
let mut ordered: Vec<&Reference> = references.iter().collect();
ordered.sort_by(|a, b| Reference::list_order(a, b));
let mut entries = Vec::new();
let mut refused = Vec::new();
let mut rendered_records = Vec::new();
for reference in ordered {
match entry(reference) {
Ok(rendered) => {
entries.push(rendered);
rendered_records.push(reference);
}
Err(refusal) => refused.push(refusal),
}
}
let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
for reference in &rendered_records {
*seen.entry(reference.id.as_str()).or_default() += 1;
}
let duplicate_ids = seen
.into_iter()
.filter(|(_, count)| *count > 1)
.map(|(id, _)| id.to_owned())
.collect();
let mut citations: Vec<(String, Vec<String>)> = Vec::new();
for reference in &rendered_records {
let Ok(citation) = in_text(reference, CitationForm::Parenthetical) else {
continue;
};
let text = citation.plain_text();
match citations.iter_mut().find(|(seen, _)| *seen == text) {
Some((_, ids)) => ids.push(reference.id.clone()),
None => citations.push((text, vec![reference.id.clone()])),
}
}
let ambiguous = citations
.into_iter()
.filter(|(_, ids)| ids.len() > 1)
.map(|(citation, reference_ids)| Ambiguity {
citation,
reference_ids,
})
.collect();
ReferenceList {
entries,
refused,
duplicate_ids,
ambiguous,
}
}
#[cfg(test)]
mod tests {
use super::{
Ambiguity, CitationForm, Entry, EntrySpan, Missing, entry, in_text, reference_list,
requires_descriptor, requires_locator, title_is_italic,
};
use rto_graph::reference::{
AccessDate, Attested, Author, Day, Doi, GivenName, Locator, Month, PublicationDate,
Reference, Stability, WorkKind, Year,
};
fn person(surname: &str, given: &str) -> Author {
Author::Person {
surname: surname.to_owned(),
given: given
.split_whitespace()
.map(|name| GivenName::new(name).expect("a valid given name"))
.collect(),
}
}
fn day(day: u8) -> Day {
Day::new(day).expect("a valid day")
}
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 doi(text: &str) -> Locator {
Locator::Doi(Doi::new(text).expect("a valid DOI"))
}
fn complete(id: &str, kind: WorkKind, title: &str) -> Reference {
let mut reference = Reference::new(id, kind, title, Stability::FixedOrArchived);
reference.authors = vec![person("Luna", "R")];
reference.published = Attested::Known(PublicationDate::Year(year(2020)));
reference.version = Attested::AbsentFromWork;
reference.descriptor = Attested::AbsentFromWork;
reference.publisher = Attested::Known("Publisher Name".to_owned());
reference.locator =
Attested::Known(Locator::Url("https://example.invalid/work".to_owned()));
reference
}
fn many_authors(count: usize) -> Vec<Author> {
(1..=count)
.map(|n| person(&format!("Author{n:02}"), "A"))
.collect()
}
fn software() -> Reference {
let mut software = complete(
"software",
WorkKind::Software,
"Comprehensive meta-analysis",
);
software.authors = vec![Author::Group("Biostat".to_owned())];
software.published = Attested::Known(PublicationDate::Year(year(2014)));
software.version = Attested::Known("3.3.070".to_owned());
software.descriptor = Attested::Known("Computer software".to_owned());
software.publisher = Attested::AbsentFromWork;
software
}
fn population_clock() -> Reference {
let mut clock = Reference::new(
"undated",
WorkKind::WebPage,
"U.S. and world population clock",
Stability::UnarchivedAndChanging {
retrieved: AccessDate {
year: year(2020),
month: Month::January,
day: day(9),
},
},
);
clock.authors = vec![Author::Group("U.S. Census Bureau".to_owned())];
clock.published = Attested::AbsentFromWork;
clock.version = Attested::AbsentFromWork;
clock.descriptor = Attested::AbsentFromWork;
clock.publisher = Attested::Known("U.S. Department of Commerce".to_owned());
clock.locator =
Attested::Known(Locator::Url("https://www.census.gov/popclock/".to_owned()));
clock
}
fn cases_by_author_shape() -> Vec<(&'static str, Reference, &'static str)> {
let one_author = complete("one", WorkKind::Document, "Title of the work");
let mut two_authors = complete("two", WorkKind::Document, "A shared title");
two_authors.authors = vec![person("Salas", "E"), person("D'Agostino", "R")];
let mut three_authors = complete("three", WorkKind::Document, "A title by three");
three_authors.authors = vec![
person("Martin", "T"),
person("Salas", "E"),
person("D'Agostino", "R"),
];
let mut corporate = complete("corporate", WorkKind::WebPage, "The top 10 causes of death");
corporate.authors = vec![Author::Group("World Health Organization".to_owned())];
corporate.published = Attested::Known(PublicationDate::Full {
year: year(2018),
month: Month::May,
day: day(24),
});
corporate.publisher = Attested::Known("World Health Organization".to_owned());
corporate.locator = Attested::Known(Locator::Url(
"https://www.who.int/news-room/fact-sheets/detail/the-top-10-causes-of-death"
.to_owned(),
));
vec![
(
"one author",
one_author,
"Luna, R. (2020). Title of the work. Publisher Name. https://example.invalid/work",
),
(
"two authors take an ampersand and the serial comma",
two_authors,
"Salas, E., & D'Agostino, R. (2020). A shared title. Publisher Name. \
https://example.invalid/work",
),
(
"three authors are all listed in the reference, however the in-text form shortens",
three_authors,
"Martin, T., Salas, E., & D'Agostino, R. (2020). A title by three. \
Publisher Name. https://example.invalid/work",
),
(
"a group author is not reduced to initials",
corporate,
"World Health Organization. (2018, May 24). The top 10 causes of death. \
https://www.who.int/news-room/fact-sheets/detail/the-top-10-causes-of-death",
),
]
}
fn cases_by_work_type() -> Vec<(&'static str, Reference, &'static str)> {
let mut with_doi = complete("doi", WorkKind::Document, "A work with a DOI");
with_doi.locator = Attested::Known(doi("10.1037/abc123"));
let mut doi_and_url = complete("doi-and-url", WorkKind::Document, "A work with both");
doi_and_url.locator = Attested::Known(Locator::Both {
doi: Doi::new("10.1037/abc123").expect("a valid DOI"),
url: "https://example.invalid/also-here".to_owned(),
});
let url_only = complete("url", WorkKind::Document, "A work with a URL only");
let mut dataset = complete(
"dataset",
WorkKind::DataSet,
"Content analysis of undergraduate psychology textbooks",
);
dataset.authors = vec![person("O'Donohue", "W")];
dataset.published = Attested::Known(PublicationDate::Year(year(2017)));
dataset.version = Attested::Known("V1".to_owned());
dataset.descriptor = Attested::Known("Data set".to_owned());
dataset.publisher = Attested::Known("ICPSR".to_owned());
dataset.locator = Attested::Known(doi("10.3886/ICPSR36966.v1"));
vec![
(
"a DOI is rendered through the resolver",
with_doi,
"Luna, R. (2020). A work with a DOI. Publisher Name. https://doi.org/10.1037/abc123",
),
(
"a DOI beats a URL when both are recorded",
doi_and_url,
"Luna, R. (2020). A work with both. Publisher Name. https://doi.org/10.1037/abc123",
),
(
"a URL is used when there is no DOI",
url_only,
"Luna, R. (2020). A work with a URL only. Publisher Name. \
https://example.invalid/work",
),
(
"a data set carries its version and its descriptor",
dataset,
"O'Donohue, W. (2017). Content analysis of undergraduate psychology textbooks \
(Version V1) [Data set]. ICPSR. https://doi.org/10.3886/ICPSR36966.v1",
),
(
"software carries its version and its descriptor",
software(),
"Biostat. (2014). Comprehensive meta-analysis (Version 3.3.070) \
[Computer software]. https://example.invalid/work",
),
(
"a genuinely undated work is n.d., and is citable",
population_clock(),
"U.S. Census Bureau. (n.d.). U.S. and world population clock. \
U.S. Department of Commerce. Retrieved January 9, 2020, from \
https://www.census.gov/popclock/",
),
]
}
fn cases_that_refuse() -> Vec<(&'static str, Reference, Vec<Missing>)> {
let mut hostile_locator = complete("hostile-locator", WorkKind::Document, "A work");
hostile_locator.locator = Attested::Known(Locator::Url("javascript:alert(1)".to_owned()));
let mut date_unknown = population_clock();
date_unknown.id = "date-unknown".to_owned();
date_unknown.published = Attested::Unknown;
let mut no_descriptor = software();
no_descriptor.id = "no-descriptor".to_owned();
no_descriptor.descriptor = Attested::Unknown;
let mut descriptor_absent = software();
descriptor_absent.id = "descriptor-absent".to_owned();
descriptor_absent.descriptor = Attested::AbsentFromWork;
let mut no_author = complete("no-author", WorkKind::Document, "An anonymous work");
no_author.authors = Vec::new();
let mut mononym = complete("mononym", WorkKind::Document, "A work by one name");
mononym.authors = vec![person("Plato", "")];
let mut nothing_known = Reference::new(
"nothing-known",
WorkKind::Software,
"",
Stability::FixedOrArchived,
);
nothing_known.authors = Vec::new();
let no_id = complete("", WorkKind::Document, "A work nobody can point at");
let mut no_source = complete("no-source", WorkKind::Document, "A work from nowhere");
no_source.publisher = Attested::AbsentFromWork;
no_source.locator = Attested::AbsentFromWork;
let mut blank_locator = complete("blank-locator", WorkKind::Document, "A work");
blank_locator.locator = Attested::Known(Locator::Url(" ".to_owned()));
vec![
(
"a date nobody has looked up refuses",
date_unknown,
vec![Missing::PublicationDate],
),
(
"a descriptor nobody recorded refuses for a kind that needs one",
no_descriptor,
vec![Missing::Descriptor],
),
(
"and so does a descriptor somebody decided does not apply",
descriptor_absent,
vec![Missing::Descriptor],
),
("no author refuses", no_author, vec![Missing::Author]),
(
"an author with no given names refuses rather than losing its initials",
mononym,
vec![Missing::AuthorInitials { position: 1 }],
),
(
"a record nobody has touched names every field at once",
nothing_known,
vec![
Missing::Author,
Missing::PublicationDate,
Missing::Title,
Missing::Version,
Missing::Descriptor,
Missing::Publisher,
Missing::Locator,
],
),
(
"neither a publisher nor a locator leaves no source element",
no_source,
vec![Missing::Source],
),
(
"a recorded but blank locator is nobody's answer, not an absence",
blank_locator,
vec![Missing::Locator],
),
(
"a locator that is not a web address is refused, never linked",
hostile_locator,
vec![Missing::Locator],
),
(
"an entry nobody can point at cannot be cited",
no_id,
vec![Missing::Identifier],
),
]
}
#[test]
fn a_complete_record_formats_to_an_apa_entry() {
let cases = cases_by_author_shape()
.into_iter()
.chain(cases_by_work_type());
for (name, reference, expected) in cases {
let rendered = entry(&reference)
.unwrap_or_else(|refusal| panic!("{name}: expected an entry, got {refusal}"));
assert_eq!(rendered.plain_text(), expected, "{name}");
assert_eq!(rendered.reference_id, reference.id, "{name}");
}
}
#[test]
fn an_incomplete_record_refuses_and_names_the_field() {
for (name, reference, expected) in cases_that_refuse() {
let refusal =
entry(&reference).expect_err(&format!("{name}: expected a refusal, got an entry"));
assert_eq!(refusal.missing, expected, "{name}");
assert_eq!(refusal.reference_id, reference.id, "{name}");
assert!(
!refusal.missing.is_empty(),
"{name}: a refusal names a field"
);
}
}
#[test]
fn an_unknown_date_never_renders_as_n_d() {
let mut undated = complete("undated", WorkKind::Document, "A title");
undated.published = Attested::AbsentFromWork;
let mut unknown = undated.clone();
unknown.id = "unknown".to_owned();
unknown.published = Attested::Unknown;
let rendered = entry(&undated)
.expect("an undated work is citable")
.plain_text();
assert!(rendered.contains("(n.d.)"), "got {rendered}");
let refusal = entry(&unknown).expect_err("an unresearched date must refuse");
assert_eq!(refusal.missing, vec![Missing::PublicationDate]);
let reported = format!("{refusal}");
assert!(
!reported.contains("n.d."),
"a refusal must not leak the undated spelling: {reported}"
);
assert_eq!(reported, "cannot cite unknown: missing publication-date");
assert!(in_text(&unknown, CitationForm::Parenthetical).is_err());
}
#[test]
fn twenty_authors_are_all_listed() {
let mut twenty = complete("twenty", WorkKind::Document, "A title by twenty");
twenty.authors = many_authors(20);
let rendered = entry(&twenty).expect("renders").plain_text();
for n in 1..=20 {
assert!(
rendered.contains(&format!("Author{n:02}, A.")),
"author {n} must be listed in full: {rendered}"
);
}
assert!(
rendered.contains(", & Author20, A."),
"the twentieth author takes the ampersand: {rendered}"
);
assert!(
!rendered.contains(". . ."),
"twenty authors are not elided: {rendered}"
);
}
#[test]
fn twenty_one_authors_are_elided_after_nineteen() {
let mut twenty_one = complete("twenty-one", WorkKind::Document, "A title by twenty-one");
twenty_one.authors = many_authors(21);
let rendered = entry(&twenty_one).expect("renders").plain_text();
for n in 1..=19 {
assert!(
rendered.contains(&format!("Author{n:02}, A.")),
"the first nineteen are listed in full, and {n} is not: {rendered}"
);
}
assert!(
rendered.contains("Author19, A., . . . Author21, A."),
"nineteen, three spaced dots, then the final author: {rendered}"
);
assert!(
rendered.contains("Author21, A."),
"the final author is always named: {rendered}"
);
for dropped in ["Author20, A.", "Author18, A., Author20"] {
assert!(
!rendered.contains(dropped),
"the twentieth of twenty-one is elided, not printed: {rendered}"
);
}
assert!(
!rendered.contains('&'),
"there is no ampersand before an elided final author: {rendered}"
);
}
#[test]
fn in_text_citations_take_et_al_from_the_first_use() {
let mut reference = complete("three", WorkKind::Document, "A title by three");
reference.authors = vec![
person("Martin", "T"),
person("Salas", "E"),
person("D'Agostino", "R"),
];
let parenthetical = in_text(&reference, CitationForm::Parenthetical)
.expect("renders")
.plain_text();
let narrative = in_text(&reference, CitationForm::Narrative)
.expect("renders")
.plain_text();
assert_eq!(parenthetical, "(Martin et al., 2020)");
assert_eq!(narrative, "Martin et al. (2020)");
assert!(
!parenthetical.contains("Salas"),
"only the first author is named: {parenthetical}"
);
}
#[test]
fn in_text_citations_by_number_of_authors() {
let mut reference = complete("r", WorkKind::Document, "A title");
let cases: Vec<(Vec<Author>, &str, &str)> = vec![
(vec![person("Luna", "R")], "(Luna, 2020)", "Luna (2020)"),
(
vec![person("Salas", "E"), person("D'Agostino", "R")],
"(Salas & D'Agostino, 2020)",
"Salas and D'Agostino (2020)",
),
(
vec![
person("Martin", "T"),
person("Salas", "E"),
person("D'Agostino", "R"),
],
"(Martin et al., 2020)",
"Martin et al. (2020)",
),
(
vec![Author::Group("World Health Organization".to_owned())],
"(World Health Organization, 2020)",
"World Health Organization (2020)",
),
];
for (authors, parenthetical, narrative) in cases {
reference.authors = authors;
assert_eq!(
in_text(&reference, CitationForm::Parenthetical)
.expect("renders")
.plain_text(),
parenthetical
);
assert_eq!(
in_text(&reference, CitationForm::Narrative)
.expect("renders")
.plain_text(),
narrative
);
}
}
#[test]
fn an_undated_work_is_n_d_in_text_too() {
let mut reference = complete("undated", WorkKind::Document, "A title");
reference.published = Attested::AbsentFromWork;
assert_eq!(
in_text(&reference, CitationForm::Parenthetical)
.expect("renders")
.plain_text(),
"(Luna, n.d.)"
);
}
#[test]
fn the_title_is_italic_as_structure_not_as_punctuation() {
let reference = complete("one", WorkKind::Document, "Title of the work");
let rendered = entry(&reference).expect("renders");
let italics: Vec<&str> = rendered
.spans
.iter()
.filter_map(|span| match span {
EntrySpan::Italic(text) => Some(text.as_str()),
_ => None,
})
.collect();
assert_eq!(italics, ["Title of the work"]);
assert!(
!rendered.plain_text().contains('*'),
"styling is a span kind, never markup in the text"
);
let links: Vec<(&str, &str)> = rendered
.spans
.iter()
.filter_map(|span| match span {
EntrySpan::Link { text, href } => Some((text.as_str(), href.as_str())),
_ => None,
})
.collect();
assert_eq!(
links,
[(
"https://example.invalid/work",
"https://example.invalid/work"
)]
);
}
#[test]
fn plain_text_is_derived_from_the_spans() {
let rendered = Entry {
reference_id: "r".to_owned(),
spans: vec![
EntrySpan::Plain("Luna, R. (2020). ".to_owned()),
EntrySpan::Italic("A title".to_owned()),
EntrySpan::Plain(". ".to_owned()),
EntrySpan::Link {
text: "https://example.invalid/x".to_owned(),
href: "https://example.invalid/x".to_owned(),
},
],
};
assert_eq!(
rendered.plain_text(),
"Luna, R. (2020). A title. https://example.invalid/x"
);
}
#[test]
fn a_retrieval_date_appears_only_where_apa_asks_for_one() {
let mut fixed = complete("fixed", WorkKind::Document, "A fixed work");
fixed.stability = Stability::FixedOrArchived;
let rendered = entry(&fixed).expect("renders").plain_text();
assert!(
!rendered.contains("Retrieved"),
"most references carry no retrieval date: {rendered}"
);
let mut changing = fixed.clone();
changing.id = "changing".to_owned();
changing.stability = Stability::UnarchivedAndChanging {
retrieved: AccessDate {
year: year(2020),
month: Month::January,
day: day(9),
},
};
let rendered = entry(&changing).expect("renders").plain_text();
assert!(
rendered.ends_with("Retrieved January 9, 2020, from https://example.invalid/work"),
"the clause sits immediately in front of the locator: {rendered}"
);
let mut nowhere = changing;
nowhere.id = "nowhere".to_owned();
nowhere.locator = Attested::AbsentFromWork;
assert_eq!(
entry(&nowhere).expect_err("refuses").missing,
vec![Missing::Locator]
);
}
#[test]
fn a_web_page_without_a_url_refuses() {
let mut page = complete("page", WorkKind::WebPage, "A page");
page.locator = Attested::AbsentFromWork;
assert_eq!(
entry(&page).expect_err("refuses").missing,
vec![Missing::Locator]
);
}
#[test]
fn a_title_ending_in_its_own_punctuation_does_not_gain_a_period() {
let mut question = complete("q", WorkKind::Document, "Who owns the future?");
question.publisher = Attested::AbsentFromWork;
question.locator = Attested::Known(Locator::Url("https://example.invalid/q".to_owned()));
assert_eq!(
entry(&question).expect("renders").plain_text(),
"Luna, R. (2020). Who owns the future? https://example.invalid/q"
);
}
#[test]
fn initials_are_derived_without_rewriting_what_was_recorded() {
let mut reference = complete("r", WorkKind::Document, "A title");
reference.authors = vec![
person("Ibáñez", "Luis Miguel"),
Author::Person {
surname: "Sartre".to_owned(),
given: vec![GivenName::new("Jean-Paul").expect("a valid given name")],
},
Author::Person {
surname: "Already".to_owned(),
given: vec![GivenName::new("M.").expect("a valid given name")],
},
];
let rendered = entry(&reference).expect("renders").plain_text();
assert!(
rendered.starts_with("Ibáñez, L. M., Sartre, J.-P., & Already, M. "),
"{rendered}"
);
}
#[test]
fn the_apa_rules_that_depend_on_the_kind_of_work() {
let cases = [
(WorkKind::Document, false, false, true),
(WorkKind::Software, true, false, true),
(WorkKind::DataSet, true, false, true),
(WorkKind::FactSheet, true, false, true),
(WorkKind::WebPage, false, true, true),
];
for (kind, descriptor, locator, italic) in cases {
assert_eq!(requires_descriptor(kind), descriptor, "{kind:?}");
assert_eq!(requires_locator(kind), locator, "{kind:?}");
assert_eq!(title_is_italic(kind), italic, "{kind:?}");
}
}
#[test]
fn only_a_web_address_becomes_a_link() {
for hostile in [
"javascript:alert(1)",
"data:text/html,<script>alert(1)</script>",
"file:///etc/passwd",
"vbscript:msgbox(1)",
" javascript:alert(1) ",
] {
let mut reference = complete("r", WorkKind::Document, "A title");
reference.locator = Attested::Known(Locator::Url(hostile.to_owned()));
assert_eq!(
entry(&reference).expect_err("refuses").missing,
vec![Missing::Locator],
"{hostile:?} must never become a link target"
);
}
for good in [
"https://example.invalid/a",
"http://example.invalid/b",
"HTTPS://example.invalid/c",
"HtTp://example.invalid/d",
] {
let mut reference = complete("r", WorkKind::Document, "A title");
reference.locator = Attested::Known(Locator::Url(good.to_owned()));
let rendered = entry(&reference).expect("renders");
assert!(rendered.plain_text().ends_with(good), "{good}");
assert!(
rendered
.spans
.iter()
.any(|span| matches!(span, EntrySpan::Link { href, .. } if href == good))
);
}
}
#[test]
fn a_descriptor_is_capitalised_but_a_title_is_never_recased() {
let mut lowercase = complete("lowercase", WorkKind::Software, "a Deliberately odd TITLE");
lowercase.version = Attested::AbsentFromWork;
lowercase.descriptor = Attested::Known("computer software".to_owned());
let rendered = entry(&lowercase).expect("renders").plain_text();
assert!(
rendered.contains("[Computer software]"),
"APA capitalises the first letter of the description: {rendered}"
);
assert!(
rendered.contains("a Deliberately odd TITLE"),
"the title is printed exactly as recorded: {rendered}"
);
}
#[test]
fn a_publisher_that_repeats_a_group_author_is_dropped_rather_than_denied() {
let mut page = complete("who", WorkKind::WebPage, "The top 10 causes of death");
page.authors = vec![Author::Group("World Health Organization".to_owned())];
page.publisher = Attested::Known("World Health Organization".to_owned());
let rendered = entry(&page).expect("renders").plain_text();
assert_eq!(
rendered.matches("World Health Organization").count(),
1,
"APA omits the publisher when it is the author: {rendered}"
);
let mut person_author = complete("person", WorkKind::Document, "A title");
person_author.publisher = Attested::Known("Luna".to_owned());
assert!(
entry(&person_author)
.expect("renders")
.plain_text()
.contains(". Luna."),
"only a group author triggers the rule"
);
let mut no_locator = page.clone();
no_locator.kind = WorkKind::Document;
no_locator.locator = Attested::AbsentFromWork;
let rendered = entry(&no_locator).expect("renders").plain_text();
assert_eq!(
rendered,
"World Health Organization. (2020). The top 10 causes of death."
);
assert_eq!(rendered.matches("World Health Organization").count(), 1);
}
#[test]
fn a_list_is_ordered_and_byte_identical_whatever_order_it_arrives_in() {
let mut zhang = complete("zhang", WorkKind::Document, "Later work");
zhang.authors = vec![person("Zhang", "I")];
let mut abbott = complete("abbott", WorkKind::Document, "Earlier work");
abbott.authors = vec![person("Abbott", "K")];
let mut abbott_undated = abbott.clone();
abbott_undated.id = "abbott-undated".to_owned();
abbott_undated.published = Attested::AbsentFromWork;
let mut martin = complete("martin", WorkKind::Document, "Unresearched work");
martin.authors = vec![person("Martin", "T")];
martin.published = Attested::Unknown;
let forwards = vec![
zhang.clone(),
abbott.clone(),
martin.clone(),
abbott_undated.clone(),
];
let backwards = vec![abbott_undated, martin, abbott, zhang];
let rendered = |set: &[Reference]| {
let list = reference_list(set);
(
list.entries
.iter()
.map(Entry::plain_text)
.collect::<Vec<_>>(),
list.refused
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
)
};
let (entries, refusals) = rendered(&forwards);
assert_eq!(
entries
.iter()
.map(|text| text.split('.').next().unwrap_or_default())
.collect::<Vec<_>>(),
["Abbott, K", "Abbott, K", "Zhang, I"],
"alphabetical by surname"
);
assert!(
entries[0].contains("(n.d.)"),
"an undated work sorts before the same author's dated ones: {}",
entries[0]
);
assert_eq!(refusals, ["cannot cite martin: missing publication-date"]);
assert_eq!(
rendered(&backwards),
(entries, refusals),
"the same set in a different order must render to the same bytes"
);
}
#[test]
fn two_people_with_one_surname_are_ordered_by_their_given_names() {
let mut anne = complete("anne", WorkKind::Document, "A title");
anne.authors = vec![person("Smith", "Anne")];
anne.published = Attested::Known(PublicationDate::Year(year(2020)));
let mut tom = complete("tom", WorkKind::Document, "A title");
tom.authors = vec![person("Smith", "Tom")];
tom.published = Attested::Known(PublicationDate::Year(year(1990)));
let list = reference_list(&[tom, anne]);
assert_eq!(
list.entries
.iter()
.map(|e| e.reference_id.as_str())
.collect::<Vec<_>>(),
["anne", "tom"]
);
}
#[test]
fn a_list_says_which_citations_a_reader_could_not_tell_apart() {
let mut first = complete("first", WorkKind::Document, "A first title");
first.authors = vec![person("Luna", "R")];
let mut second = complete("second", WorkKind::Document, "A second title");
second.authors = vec![person("Luna", "R")];
let mut other = complete("other", WorkKind::Document, "Another title");
other.authors = vec![person("Abbott", "K")];
let list = reference_list(&[first, second, other]);
assert_eq!(list.entries.len(), 3, "every entry is still rendered");
assert_eq!(
list.ambiguous,
vec![Ambiguity {
citation: "(Luna, 2020)".to_owned(),
reference_ids: vec!["first".to_owned(), "second".to_owned()],
}],
"the unambiguous entry is not reported"
);
assert!(list.duplicate_ids.is_empty());
}
#[test]
fn a_list_says_when_two_records_share_an_identifier() {
let mut one = complete("same", WorkKind::Document, "A first title");
one.authors = vec![person("Luna", "R")];
let mut two = complete("same", WorkKind::Document, "A second title");
two.authors = vec![person("Abbott", "K")];
let list = reference_list(&[one, two]);
assert_eq!(list.entries.len(), 2);
assert_eq!(list.duplicate_ids, ["same"]);
assert!(
list.ambiguous.is_empty(),
"different authors, so the citations themselves are distinct"
);
}
#[test]
fn no_part_of_a_recorded_name_is_dropped_from_a_rendered_author() {
for undroppable in [
"",
" ",
"Jean--Paul",
"-Paul",
"Jean-",
"Mary Ann",
"Mary Ann",
"José María",
] {
assert!(
GivenName::new(undroppable).is_err(),
"{undroppable:?} must not be constructible, so no renderer can drop it"
);
}
for (given, rendered) in [
(vec!["Mary"], "Smith, M."),
(vec!["Mary", "Ann"], "Smith, M. A."),
(vec!["Jean-Paul"], "Smith, J.-P."),
(vec!["M.", "A."], "Smith, M. A."),
] {
let mut reference = complete("r", WorkKind::Document, "A title");
reference.authors = vec![Author::Person {
surname: "Smith".to_owned(),
given: given.iter().map(|n| given_name(n)).collect(),
}];
let text = entry(&reference).expect("renders").plain_text();
assert!(text.starts_with(rendered), "{given:?} rendered {text:?}");
}
let mut mononym = complete("r", WorkKind::Document, "A title");
mononym.authors = vec![Author::Person {
surname: "Plato".to_owned(),
given: Vec::new(),
}];
assert_eq!(
entry(&mononym).expect_err("refuses").missing,
vec![Missing::AuthorInitials { position: 1 }]
);
}
#[test]
fn a_url_a_reader_cannot_see_whole_is_not_a_link() {
for hidden in [
"https://example.invalid/a\nb",
"https://example.invalid/a b",
"https://example.invalid/a\tb",
"https://example.invalid/a\u{202e}b",
"https://example.invalid/a\u{200b}b",
"https://example.invalid/a\u{061c}b",
"https://example.invalid/a\u{206a}b",
"https://example.invalid/a\u{00ad}b",
"https://example.invalid/a\u{e0041}b",
] {
let mut reference = complete("r", WorkKind::Document, "A title");
reference.locator = Attested::Known(Locator::Url(hidden.to_owned()));
assert_eq!(
entry(&reference).expect_err("refuses").missing,
vec![Missing::Locator],
"{hidden:?} must never become a link target"
);
}
}
#[test]
fn a_locator_is_validated_exactly_as_recorded() {
for padded in [
" https://example.invalid/a ",
"https://example.invalid/a\n",
"\thttps://example.invalid/a",
"https://example.invalid/a\u{a0}",
] {
let mut reference = complete("r", WorkKind::Document, "A title");
reference.locator = Attested::Known(Locator::Url(padded.to_owned()));
assert_eq!(
entry(&reference).expect_err("refuses").missing,
vec![Missing::Locator],
"{padded:?} must not be trimmed into validity"
);
}
let recorded = "https://example.invalid/a";
let mut reference = complete("r", WorkKind::Document, "A title");
reference.locator = Attested::Known(Locator::Url(recorded.to_owned()));
let spans = entry(&reference).expect("renders").spans;
let href = spans
.iter()
.find_map(|span| match span {
EntrySpan::Link { href, .. } => Some(href.clone()),
_ => None,
})
.expect("a link span");
assert_eq!(href, recorded, "printed as recorded");
}
#[test]
fn a_locator_with_no_host_cannot_locate_a_work() {
for hostless in [
"https://",
"http://",
"https://?",
"https://#frag",
"https:///path",
"HTTPS://",
] {
let mut reference = complete("r", WorkKind::Document, "A title");
reference.locator = Attested::Known(Locator::Url(hostless.to_owned()));
assert_eq!(
entry(&reference).expect_err("refuses").missing,
vec![Missing::Locator],
"{hostless:?} has no authority and cannot locate anything"
);
}
for locatable in [
"https://localhost/x",
"http://example.invalid",
"https://example.invalid/a?b#c",
] {
let mut reference = complete("r", WorkKind::Document, "A title");
reference.locator = Attested::Known(Locator::Url(locatable.to_owned()));
assert!(entry(&reference).is_ok(), "{locatable:?}");
}
}
#[test]
fn a_field_that_brings_its_own_label_is_not_citable_as_written() {
for doubled in ["Version 3", "version 3.3.070", "VERSION 1", "Version"] {
let mut reference = complete("r", WorkKind::Document, "A title");
reference.version = Attested::Known(doubled.to_owned());
assert_eq!(
entry(&reference).expect_err("refuses").missing,
vec![Missing::Version],
"{doubled:?} would render its label twice"
);
}
for fine in ["V1", "3.3.070", "b10200", "2.0-rc1", "Versionless"] {
let mut reference = complete("r", WorkKind::Document, "A title");
reference.version = Attested::Known(fine.to_owned());
let rendered = entry(&reference).expect("renders").plain_text();
assert!(
rendered.contains(&format!("(Version {fine})")),
"{rendered}"
);
}
for bracketed in ["[Data set]", "Data set]", "[Computer software"] {
let mut reference = complete("r", WorkKind::DataSet, "A title");
reference.descriptor = Attested::Known(bracketed.to_owned());
assert_eq!(
entry(&reference).expect_err("refuses").missing,
vec![Missing::Descriptor],
"{bracketed:?} would render its brackets twice"
);
}
let mut bare = complete("r", WorkKind::DataSet, "A title");
bare.descriptor = Attested::Known("Data set".to_owned());
assert!(
entry(&bare)
.expect("renders")
.plain_text()
.contains("[Data set]")
);
}
#[test]
fn a_date_that_did_not_happen_refuses_by_its_own_name() {
let mut published = complete("p", WorkKind::Document, "A title");
published.published = Attested::Known(PublicationDate::Full {
year: year(2021),
month: Month::February,
day: day(31),
});
assert_eq!(
entry(&published).expect_err("refuses").missing,
vec![Missing::PublicationDate]
);
let mut retrieved = complete("r", WorkKind::WebPage, "A title");
retrieved.stability = Stability::UnarchivedAndChanging {
retrieved: AccessDate {
year: year(2021),
month: Month::February,
day: day(31),
},
};
let refusal = entry(&retrieved).expect_err("refuses");
assert_eq!(refusal.missing, vec![Missing::RetrievalDate]);
assert!(refusal.to_string().contains("retrieval-date"), "{refusal}");
let mut leap = complete("l", WorkKind::Document, "A title");
leap.published = Attested::Known(PublicationDate::Full {
year: year(2020),
month: Month::February,
day: day(29),
});
assert!(
entry(&leap)
.expect("renders")
.plain_text()
.contains("(2020, February 29)")
);
}
#[test]
fn a_locator_with_no_real_host_cannot_locate_a_work() {
for hostless in [
"https://:443",
"https://@/path",
"https://[]",
"https://[]:80/x",
"https://user@:8080/x",
"https://[unclosed/x",
] {
let mut reference = complete("r", WorkKind::Document, "A title");
reference.locator = Attested::Known(Locator::Url(hostless.to_owned()));
assert_eq!(
entry(&reference).expect_err("refuses").missing,
vec![Missing::Locator],
"{hostless:?} names no host"
);
}
for locatable in [
"https://[::1]/x",
"https://[::1]:8080/x",
"https://user@host/x",
"https://user:pw@host:8080/x",
"https://host:8080/x",
"https://localhost",
] {
let mut reference = complete("r", WorkKind::Document, "A title");
reference.locator = Attested::Known(Locator::Url(locatable.to_owned()));
assert!(entry(&reference).is_ok(), "{locatable:?}");
}
}
#[test]
fn a_whole_rendered_list_is_byte_identical_whatever_order_it_arrives_in() {
let mut first = complete("first", WorkKind::Document, "A first title");
first.authors = vec![person("Luna", "R")];
let mut second = complete("second", WorkKind::Document, "A second title");
second.authors = vec![person("Luna", "R")];
let mut shared_id = complete("shared", WorkKind::Document, "A third title");
shared_id.authors = vec![person("Abbott", "K")];
let mut shared_id_too = complete("shared", WorkKind::Document, "A fourth title");
shared_id_too.authors = vec![person("Zhang", "I")];
let mut refuses = complete("refuses", WorkKind::Document, "A fifth title");
refuses.authors = vec![person("Martin", "P")];
refuses.published = Attested::Unknown;
let forwards = vec![
first.clone(),
second.clone(),
shared_id.clone(),
shared_id_too.clone(),
refuses.clone(),
];
let backwards: Vec<Reference> = forwards.iter().rev().cloned().collect();
let list = reference_list(&forwards);
assert_eq!(list.entries.len(), 4);
assert_eq!(list.refused.len(), 1);
assert_eq!(list.duplicate_ids, ["shared"]);
assert_eq!(
list.ambiguous,
vec![Ambiguity {
citation: "(Luna, 2020)".to_owned(),
reference_ids: vec!["first".to_owned(), "second".to_owned()],
}]
);
let serialised =
|set: &[Reference]| serde_json::to_string(&reference_list(set)).expect("serialize");
assert_eq!(
serialised(&forwards),
serialised(&backwards),
"the whole report, not just the entries, must be a function of the input set"
);
}
#[test]
fn rendering_the_same_list_twice_gives_the_same_bytes() {
let mut set = Vec::new();
for (index, surname) in ["Salas", "abbott", "Zhang", "Abbott"].iter().enumerate() {
let mut reference =
complete(&format!("r{index}"), WorkKind::Document, "A shared title");
reference.authors = vec![person(surname, "A")];
set.push(reference);
}
let once = serde_json::to_string(&reference_list(&set)).expect("serialize");
let twice = serde_json::to_string(&reference_list(&set)).expect("serialize");
assert_eq!(
once, twice,
"rendering must be a pure function of the input"
);
}
}