rto_graph/reference.rs
1//! A citable external work — the record a reference list is rendered *from*
2//! (issue #801, phase 3).
3//!
4//! This module holds the data and nothing else. It knows what we have been told
5//! about a work; it has no opinion about how a reference is spelled, which
6//! fields a citation style insists on, or whether the result is fit to publish.
7//! Those are a renderer's judgements and live with the renderer — `rto_render`'s
8//! `apa` module. That is deliberately not a link: this crate does not depend on
9//! `rto-render`, and a link that resolved would mean the dependency ran the
10//! wrong way.
11//!
12//! # The three states, and why two of them look alike
13//!
14//! The defect this module exists to prevent is a *plausible* citation. A
15//! reference with a guessed year is worse than no reference at all, because it
16//! survives review: it looks like the others.
17//!
18//! Almost every field of a real bibliographic record is in one of three states,
19//! and the middle one is the trap:
20//!
21//! | state | meaning | citable? |
22//! |---|---|---|
23//! | [`Attested::Known`] | we hold the value | yes |
24//! | [`Attested::AbsentFromWork`] | somebody looked, and the **work** has none | yes — APA writes `n.d.` for a dateless work, and that is an honest thing to write |
25//! | [`Attested::Unknown`] | nobody has looked it up | **no** |
26//!
27//! `AbsentFromWork` and `Unknown` are both "there is no value here", which is
28//! exactly why they must not share a spelling. `n.d.` is a claim about the work
29//! — it asserts that the work carries no date. Rendering an unresearched field
30//! as `n.d.` publishes that claim on no evidence, and it is indistinguishable
31//! in the output from the honest case. So the two are separate variants, the
32//! [`Default`] is [`Attested::Unknown`], and a record built field by field
33//! refuses until somebody has been through it.
34//!
35//! There is no `Option` on any field a renderer consults, for the same reason:
36//! `None` would collapse the two states back together.
37//!
38//! # What is unrepresentable rather than refused
39//!
40//! Two conditions are modelled so that the bad state cannot be written down at
41//! all, which is stronger than catching it later:
42//!
43//! - **A retrieval date belongs to the condition that requires it.** APA asks
44//! for one only where a work is designed to change and is not archived, so
45//! the date lives *inside* that variant of [`Stability`]. "Changeable, with
46//! no retrieval date" and "stable, but here is a retrieval date I will
47//! quietly emit" are both unspellable.
48//! - **A publication date is a year, or a year and a month, or a full date.**
49//! [`PublicationDate`] is an enum rather than a struct of `Option`s, so a day
50//! without a month cannot be recorded.
51//!
52//! # No clock, and no network
53//!
54//! Nothing here reads the time. A retrieval date is *data on the record*,
55//! written down by whoever did the retrieving; `SystemTime::now()` would make a
56//! rendered bundle differ on every build, and ADR-0013 is explicit that no
57//! wall-clock value may reach a query. The module is a plain data definition:
58//! no I/O, no clock, no graph access.
59//!
60//! It lives in this crate — not in the renderer — for two reasons. The
61//! extraction layer that will eventually populate these records is here, and a
62//! type cannot be shared upwards: `rto-render` depends on `rto-graph`, so the
63//! record must sit in the lower crate for both to name it. And this is the
64//! crate that *structurally* cannot reach the network — `gix` is pinned
65//! `default-features = false` to exclude transports (ADR-0019 §2) — which is
66//! the guarantee worth having over a type whose every field invites a lookup.
67
68use std::cmp::Ordering;
69use std::fmt;
70
71use serde::{Deserialize, Serialize};
72
73/// What is known about one field of a [`Reference`].
74///
75/// See the module documentation for why the absent and unknown cases are
76/// separate variants rather than one `Option`.
77///
78/// Deliberately not `#[non_exhaustive]`. The three states *are* the model, and
79/// the defect this type exists to prevent is precisely a caller folding two of
80/// them into one arm. Closing the set makes every consumer's match a compile
81/// error the day a fourth epistemic state is proposed — which is when that
82/// proposal should be argued, rather than absorbed by a wildcard.
83#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
84#[serde(rename_all = "kebab-case")]
85pub enum Attested<T> {
86 /// We hold the value.
87 Known(T),
88 /// Somebody looked, and the work has none. A renderer may act on this: a
89 /// work with no date is cited `n.d.`, a work with no publisher simply omits
90 /// that element.
91 AbsentFromWork,
92 /// Nobody has looked this up. It is not a fact about the work at all — only
93 /// a record of our own ignorance — and no renderer may turn it into output.
94 Unknown,
95}
96
97/// [`Attested::Unknown`], always.
98///
99/// Implemented by hand rather than derived: `#[derive(Default)]` would demand
100/// `T: Default` for no reason, and — more to the point — the default has to be
101/// the state that refuses, so that a record assembled field by field cannot
102/// acquire a citable-looking hole.
103impl<T> Default for Attested<T> {
104 fn default() -> Self {
105 Self::Unknown
106 }
107}
108
109impl<T> Attested<T> {
110 /// The value, if we hold one.
111 #[must_use]
112 pub fn known(&self) -> Option<&T> {
113 match self {
114 Self::Known(value) => Some(value),
115 Self::AbsentFromWork | Self::Unknown => None,
116 }
117 }
118
119 /// Whether we hold a value.
120 #[must_use]
121 pub fn is_known(&self) -> bool {
122 matches!(self, Self::Known(_))
123 }
124
125 /// Whether somebody established that the work has no such value.
126 #[must_use]
127 pub fn is_absent_from_work(&self) -> bool {
128 matches!(self, Self::AbsentFromWork)
129 }
130
131 /// Whether nobody has looked this up.
132 #[must_use]
133 pub fn is_unknown(&self) -> bool {
134 matches!(self, Self::Unknown)
135 }
136}
137
138/// A calendar month, named rather than numbered so that an impossible month
139/// cannot be recorded.
140///
141/// Deliberately not `#[non_exhaustive]`: the Gregorian calendar has twelve
142/// months, and is not expected to grow one.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
144#[serde(rename_all = "kebab-case")]
145pub enum Month {
146 /// January.
147 January,
148 /// February.
149 February,
150 /// March.
151 March,
152 /// April.
153 April,
154 /// May.
155 May,
156 /// June.
157 June,
158 /// July.
159 July,
160 /// August.
161 August,
162 /// September.
163 September,
164 /// October.
165 October,
166 /// November.
167 November,
168 /// December.
169 December,
170}
171
172impl Month {
173 /// The English month name, capitalised — the spelling APA uses in a date
174 /// element (`2020, August 26`).
175 #[must_use]
176 pub fn name(self) -> &'static str {
177 match self {
178 Self::January => "January",
179 Self::February => "February",
180 Self::March => "March",
181 Self::April => "April",
182 Self::May => "May",
183 Self::June => "June",
184 Self::July => "July",
185 Self::August => "August",
186 Self::September => "September",
187 Self::October => "October",
188 Self::November => "November",
189 Self::December => "December",
190 }
191 }
192
193 /// The month number, 1 for January through 12 for December. Used for
194 /// ordering, never for rendering.
195 #[must_use]
196 pub fn number(self) -> u8 {
197 match self {
198 Self::January => 1,
199 Self::February => 2,
200 Self::March => 3,
201 Self::April => 4,
202 Self::May => 5,
203 Self::June => 6,
204 Self::July => 7,
205 Self::August => 8,
206 Self::September => 9,
207 Self::October => 10,
208 Self::November => 11,
209 Self::December => 12,
210 }
211 }
212
213 /// Parse a month from its number, 1 through 12.
214 #[must_use]
215 pub fn from_number(number: u8) -> Option<Self> {
216 Some(match number {
217 1 => Self::January,
218 2 => Self::February,
219 3 => Self::March,
220 4 => Self::April,
221 5 => Self::May,
222 6 => Self::June,
223 7 => Self::July,
224 8 => Self::August,
225 9 => Self::September,
226 10 => Self::October,
227 11 => Self::November,
228 12 => Self::December,
229 _ => return None,
230 })
231 }
232}
233
234/// A day of the month, 1 through 31.
235///
236/// A newtype rather than a bare `u8` so that `0` and `47` are not writable, and
237/// so that a record read back from disk is validated on the way in: the serde
238/// representation is the number, parsed through [`Day::new`].
239#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
240#[serde(try_from = "u8", into = "u8")]
241pub struct Day(u8);
242
243impl Day {
244 /// Construct a day of the month, rejecting anything outside 1..=31.
245 ///
246 /// The upper bound is the widest month rather than the right one for a
247 /// given month and year: this type carries no calendar, and a bound that
248 /// needed one would be a computation over data the record does not hold.
249 #[must_use]
250 pub fn new(day: u8) -> Option<Self> {
251 (1..=31).contains(&day).then_some(Self(day))
252 }
253
254 /// The day number.
255 #[must_use]
256 pub fn get(self) -> u8 {
257 self.0
258 }
259}
260
261/// A day outside 1..=31.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
263#[error("not a day of the month: {0} (expected 1..=31)")]
264pub struct NotADay(pub u8);
265
266impl TryFrom<u8> for Day {
267 type Error = NotADay;
268
269 fn try_from(day: u8) -> Result<Self, Self::Error> {
270 Self::new(day).ok_or(NotADay(day))
271 }
272}
273
274impl From<Day> for u8 {
275 fn from(day: Day) -> Self {
276 day.0
277 }
278}
279
280/// A year a work was published or was retrieved in, 1 or later.
281///
282/// A newtype for the same reason [`Day`] is one, and it was missing for the
283/// same reason `Day`'s bound nearly was: a bare `i32` let `0` and `-1` through
284/// `serde`, and the formatter rendered them as `(0)` and `(-1, January 2)` —
285/// strings that are not dates, printed with a date's authority, in a module
286/// whose subject is not doing that.
287///
288/// Year zero is not a year in the numbering APA's readers use, and a negative
289/// year is BCE, which APA writes as `400 B.C.E.` and not as `-400`. Rendering
290/// either as a plain number states something false about the work, so neither
291/// is constructible. BCE dates are not modelled at all: the alternative is an
292/// era field, and half an era model is worse than none.
293///
294/// There is deliberately **no upper bound**. A year in the future is either a
295/// forthcoming work — a legitimate thing to record — or a typo, and no type can
296/// separate those without a clock. This crate has no clock and will not acquire
297/// one: a record that rendered differently next year would not be reproducible,
298/// which is a harder rule here than tidiness about typos.
299#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
300#[serde(try_from = "i32", into = "i32")]
301pub struct Year(i32);
302
303impl Year {
304 /// Construct a year, rejecting zero and anything before it.
305 #[must_use]
306 pub fn new(year: i32) -> Option<Self> {
307 (year >= 1).then_some(Self(year))
308 }
309
310 /// The year number.
311 #[must_use]
312 pub fn get(self) -> i32 {
313 self.0
314 }
315}
316
317/// A year of zero or less.
318#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
319#[error("not a year: {0} (expected 1 or later; BCE dates are not modelled)")]
320pub struct NotAYear(pub i32);
321
322impl TryFrom<i32> for Year {
323 type Error = NotAYear;
324
325 fn try_from(year: i32) -> Result<Self, Self::Error> {
326 Self::new(year).ok_or(NotAYear(year))
327 }
328}
329
330impl From<Year> for i32 {
331 fn from(year: Year) -> Self {
332 year.0
333 }
334}
335
336impl fmt::Display for Year {
337 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
338 write!(f, "{}", self.0)
339 }
340}
341
342/// When a work was published.
343///
344/// An enum rather than a struct of `Option`s so that a day without a month is
345/// unrepresentable. Note that this models the *precision* of a known date, not
346/// whether one is known at all — that is [`Attested`]'s job, and a
347/// [`Reference::published`] of [`Attested::AbsentFromWork`] is what becomes
348/// `n.d.`.
349///
350/// Deliberately not `#[non_exhaustive]`. APA recognises date shapes this does
351/// not carry yet — a season, a range — so the set may well grow. That is the
352/// reason for closing it: when it grows, every renderer stops compiling until
353/// it says how the new shape is printed, where a wildcard arm would print it as
354/// something else. Rendering a date as a date it is not is the failure this
355/// whole module is about.
356#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
357#[serde(rename_all = "kebab-case")]
358pub enum PublicationDate {
359 /// The year alone — the commonest case, and all APA requires of most works.
360 Year(Year),
361 /// A year and a month.
362 YearMonth {
363 /// The year.
364 year: Year,
365 /// The month.
366 month: Month,
367 },
368 /// A full calendar date.
369 Full {
370 /// The year.
371 year: Year,
372 /// The month.
373 month: Month,
374 /// The day of the month.
375 day: Day,
376 },
377}
378
379/// Whether `day` exists in that month of that year.
380///
381/// The proleptic Gregorian calendar, which is the one APA's dates are read in.
382/// It lives here rather than on [`Day`] because it is not a property of a day:
383/// `31` is a perfectly good day number, and only `31` *together with* February
384/// is impossible. That is the shape of this defect and of the several before it
385/// — an invariant enforced on a part while the composite goes unchecked — so it
386/// is checked where the composite is, and by both types that build one.
387fn day_exists(year: Year, month: Month, day: Day) -> bool {
388 let leap = |y: i32| y % 4 == 0 && (y % 100 != 0 || y % 400 == 0);
389 let length = match month {
390 Month::January
391 | Month::March
392 | Month::May
393 | Month::July
394 | Month::August
395 | Month::October
396 | Month::December => 31,
397 Month::April | Month::June | Month::September | Month::November => 30,
398 Month::February => {
399 if leap(year.get()) {
400 29
401 } else {
402 28
403 }
404 }
405 };
406 day.get() <= length
407}
408
409impl PublicationDate {
410 /// Whether this names a day that exists.
411 ///
412 /// Always true for the `Year` and `YearMonth` precisions, which cannot name
413 /// a day at all. For `Full` it is the question [`Day`] cannot answer on its
414 /// own: `Day::new(31)` is valid, and `February 31` is not.
415 ///
416 /// A method rather than a constructor because making the state
417 /// unrepresentable means giving this enum an opaque shape — a smart
418 /// constructor and private fields on a public enum — which is a wider change
419 /// than the defect warrants. So the formatter refuses instead, which is the
420 /// module's other setting and the one it uses wherever a record can be
421 /// *written* but not *cited*.
422 #[must_use]
423 pub fn names_a_day_that_exists(self) -> bool {
424 match self {
425 Self::Year(_) | Self::YearMonth { .. } => true,
426 Self::Full { year, month, day } => day_exists(year, month, day),
427 }
428 }
429
430 /// The year, whatever the precision. An in-text citation uses only this.
431 #[must_use]
432 pub fn year(self) -> Year {
433 match self {
434 Self::Year(year) | Self::YearMonth { year, .. } | Self::Full { year, .. } => year,
435 }
436 }
437}
438
439/// The date on which somebody retrieved a work, written down by whoever did it.
440///
441/// Always a full date: APA's retrieval clause is `Retrieved January 9, 2020,
442/// from …`, so a partial one could not be rendered. It reaches a record only
443/// through [`Stability::UnarchivedAndChanging`], which is the single condition
444/// under which APA wants it at all.
445#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
446pub struct AccessDate {
447 /// The year.
448 pub year: Year,
449 /// The month.
450 pub month: Month,
451 /// The day of the month.
452 pub day: Day,
453}
454
455impl AccessDate {
456 /// Whether this names a day that exists — see
457 /// [`PublicationDate::names_a_day_that_exists`], which this shares its
458 /// calendar with.
459 ///
460 /// A retrieval date is always full precision, so unlike a publication date
461 /// there is no case where the question does not arise. Without it the
462 /// formatter emitted `Retrieved February 31, 2021, from …` — a statement
463 /// that somebody read a work on a day that did not happen.
464 #[must_use]
465 pub fn names_a_day_that_exists(self) -> bool {
466 day_exists(self.year, self.month, self.day)
467 }
468}
469
470/// Whether a work holds still.
471///
472/// This is the condition APA attaches a retrieval date to, and the date is
473/// carried *inside* the variant that requires it. The two failure modes a
474/// separate `retrieved` field would allow — a changeable work with no retrieval
475/// date, and a stable work carrying one that gets emitted anyway — are then not
476/// mistakes to catch but sentences that cannot be written.
477///
478/// Deliberately not `#[non_exhaustive]`: APA's condition is a yes-or-no
479/// question about one work, so the set has exactly two answers.
480#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
481#[serde(rename_all = "kebab-case")]
482pub enum Stability {
483 /// The work is fixed, or an archived or versioned copy of what was read
484 /// still exists. No retrieval date; this is the ordinary case, and APA is
485 /// explicit that most references carry none.
486 FixedOrArchived,
487 /// The work is designed to change over time and is *not* archived, so the
488 /// only honest way to cite it is to say when it was read.
489 UnarchivedAndChanging {
490 /// When it was read.
491 retrieved: AccessDate,
492 },
493}
494
495/// One given name, as the work spells it: `Mary`, or `M.` where that is all
496/// anybody recorded.
497///
498/// A validating newtype for the same reason [`Day`] is one, and the defect that
499/// produced it is the sharpest example in this module of the rule the module
500/// exists for. The formatter used to reduce given names to initials with a
501/// `filter_map`, so a fragment it could not turn into an initial was **dropped**
502/// — `given = ["Mary", ""]` rendered `Smith, M.` and `"Jean--Paul"` rendered
503/// `J.-P.`. Both are plausible authors with part of the recorded name missing,
504/// and a citation that looks authoritative and is partly invented is exactly
505/// what this module refuses to emit. Handling it at the one call site would
506/// have left the *spelling* available; making the value unconstructible means
507/// no later renderer can reintroduce it, and `serde` refuses one on the way in
508/// from a file as well.
509///
510/// What is accepted is stated as an **allowlist** — see
511/// `is_given_name_char` — rather than as a list of the malformed shapes to
512/// reject. The first attempt at this was the latter, and a space walked through
513/// it: `"Mary Ann"` was one legal value that rendered `Smith, M.`, dropping
514/// `Ann` by exactly the mechanism the newtype was introduced to close. A rule
515/// that enumerates the bad separators is a rule waiting for the next separator.
516///
517/// Accepted: `Mary`, `M.`, `Jean-Paul`, `Ibáñez`, `N'Golo`. Rejected: the empty
518/// string, whitespace alone, `Jean--Paul`, `-Paul`, `Jean-`, `"Mary Ann"`, and
519/// anything carrying a digit, a separator or an invisible character.
520///
521/// Also rejected: a **generational suffix** (`Jr.`, `Sr.`, `II`, `III`, `IV`).
522/// Those are not given names, and APA prints them in a position this record has
523/// no field for — `Smith, J., Jr.`. Recorded among the given names they render
524/// as an invented middle initial, `Smith, J. J.`, which is the same fabrication
525/// by another route. Refusing means a real name is unrecordable until the record
526/// grows a field for it, which is the trade this module already makes for a
527/// mononym author: a visible refusal beats a quiet wrong answer.
528#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
529#[serde(try_from = "String", into = "String")]
530pub struct GivenName(String);
531
532/// A string that is not a usable given name.
533#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
534#[error(
535 "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)"
536)]
537pub struct NotAGivenName(pub String);
538
539/// Whether `c` may appear inside one hyphen-separated part of a given name.
540///
541/// An **allowlist**, for the reason spelled out on [`is_printable_identifier`],
542/// and this one earned it the same way. The rule this replaced split a name on
543/// hyphens and required each part to be non-blank — an enumeration of the
544/// separators somebody had thought of, which is a list with a space missing from
545/// it. `"Mary Ann"` passed, and rendered `Smith, M.`, dropping `Ann`: the exact
546/// silent drop the newtype had just been introduced to make unspellable, through
547/// the one separator the rule did not name. Unicode has many more.
548///
549/// So: letters of **any** script, because this is not a rule about English.
550/// Combining diacritics U+0300–U+036F, so a name recorded in decomposed form
551/// (`e` followed by U+0301, which is what macOS hands you) is the same name as
552/// its composed spelling rather than a refusal. A full stop, because `M.` is a
553/// value this record is explicitly allowed to hold. An apostrophe in both its
554/// spellings, because `N'Golo` and `N’Golo` are one name typed two ways.
555///
556/// Everything else is refused: digits, every kind of whitespace, every
557/// separator, every format and control character. A name this turns away is a
558/// name somebody can come and argue for; a name the old rule let through was a
559/// citation with a piece missing and nothing to show for it.
560fn is_given_name_char(c: char) -> bool {
561 c.is_alphabetic()
562 || ('\u{0300}'..='\u{036F}').contains(&c)
563 || matches!(c, '.' | '\'' | '\u{2019}')
564}
565
566/// Whether `name` is a generational suffix rather than a given name.
567///
568/// A deliberately short, closed list, and not a rule about spelling — these are
569/// perfectly good strings. It is a statement that the record has nowhere to put
570/// them: APA writes them after the initials, and [`Author::Person`] has two
571/// fields, neither of which is that position.
572///
573/// `Jr` and `Sr` match case-insensitively, with or without the period, because
574/// that is the range of ways they are printed. The Roman numerals match exactly,
575/// since lower-case `iii` is not how anybody writes one. `I` and `V` are left
576/// out on purpose: `V.` is Victor's initial far more often than it is a fifth,
577/// and refusing a real initial to catch a rare suffix is the wrong way round.
578fn is_generational_suffix(name: &str) -> bool {
579 ["jr", "jr.", "sr", "sr."].contains(&name.to_ascii_lowercase().as_str())
580 || ["II", "III", "IV"].contains(&name)
581}
582
583impl GivenName {
584 /// Parse a given name.
585 ///
586 /// Outer whitespace is trimmed and the trimmed form is what is stored: the
587 /// renderer trims, and a leading space no reader can see must not be able to
588 /// decide where an entry lands in a list somebody reads. Nothing *inside*
589 /// the name is rewritten.
590 ///
591 /// Each hyphen-separated part must **begin with a letter** and otherwise
592 /// contain only given-name characters (`is_given_name_char`). Beginning with
593 /// a letter is what makes [`GivenName::initial`] total: every part has a
594 /// letter to reduce to, so there is no part it can fail on and therefore
595 /// none it could be tempted to skip.
596 ///
597 /// # One value is one name
598 ///
599 /// A given name holding internal whitespace is **refused**, and this is a
600 /// decision rather than a side effect. `"Mary Ann"` has two readings — two
601 /// given names that belong in two elements of `given`, rendering `M. A.`, or
602 /// one compound name rendering `M.` — and a formatter picking between them
603 /// is guessing at what somebody meant. The caller knows; this type does not.
604 /// So the ambiguous spelling is not recordable, and the unambiguous one is:
605 /// `given = [GivenName::new("Mary")?, GivenName::new("Ann")?]`.
606 ///
607 /// The alternative — accept it and render `M. A.` by splitting on whitespace
608 /// — was rejected because it makes the compound reading unspellable instead,
609 /// and a compound given name is a real thing. Refusing leaves *both* readings
610 /// expressible, one of them per element; accepting would silently pick one.
611 ///
612 /// # Errors
613 ///
614 /// Returns [`NotAGivenName`] for a blank name, a part not starting with a
615 /// letter, any character outside the allowlist (including whitespace), or a
616 /// generational suffix.
617 pub fn new(text: &str) -> Result<Self, NotAGivenName> {
618 let trimmed = text.trim();
619 let is_a_name = |part: &str| {
620 part.starts_with(char::is_alphabetic) && part.chars().all(is_given_name_char)
621 };
622 let usable = !trimmed.is_empty()
623 && trimmed.split('-').all(is_a_name)
624 && !is_generational_suffix(trimmed);
625 if usable {
626 Ok(Self(trimmed.to_owned()))
627 } else {
628 Err(NotAGivenName(text.to_owned()))
629 }
630 }
631
632 /// The name as recorded.
633 #[must_use]
634 pub fn as_str(&self) -> &str {
635 &self.0
636 }
637
638 /// The hyphen-separated parts. Each begins with a letter, by construction,
639 /// and a part carries no whitespace — so a part is exactly one name and
640 /// there is exactly one initial to take from it.
641 pub fn parts(&self) -> impl Iterator<Item = &str> {
642 self.0.split('-')
643 }
644
645 /// The initial APA prints for this name: `M.` for `Mary` and for `M.`, and
646 /// `J.-P.` for `Jean-Paul`.
647 ///
648 /// It lives on the record rather than in the formatter because two callers
649 /// need it and they must not be allowed to disagree: the formatter prints
650 /// it, and [`Reference::list_order`] alphabetises on it. APA alphabetises a
651 /// list by what the list *prints*, so ordering on the recorded name instead
652 /// lets `Mary` and `M.` — one rendered author, two spellings — come out in
653 /// an order no reader of the page can account for. One derivation, both
654 /// callers; two copies of it is how they came apart in the first place.
655 ///
656 /// `map` rather than `filter_map`, deliberately: a part is non-blank by
657 /// construction, and if that ever stopped being true this should produce a
658 /// visibly wrong initial rather than quietly one part short.
659 #[must_use]
660 pub fn initial(&self) -> String {
661 self.parts()
662 .map(|part| {
663 part.chars()
664 .next()
665 .map_or_else(String::new, |first| format!("{}.", first.to_uppercase()))
666 })
667 .collect::<Vec<_>>()
668 .join("-")
669 }
670}
671
672impl TryFrom<String> for GivenName {
673 type Error = NotAGivenName;
674
675 fn try_from(text: String) -> Result<Self, Self::Error> {
676 Self::new(&text)
677 }
678}
679
680impl From<GivenName> for String {
681 fn from(name: GivenName) -> Self {
682 name.0
683 }
684}
685
686impl fmt::Display for GivenName {
687 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
688 f.write_str(&self.0)
689 }
690}
691
692/// Who produced a work.
693///
694/// Deliberately not `#[non_exhaustive]`. The person/group distinction is the
695/// one that decides whether a name may be reduced to initials, and a consumer
696/// meeting an unknown third kind behind a wildcard would have to guess which
697/// side of that line it fell on.
698#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
699#[serde(rename_all = "kebab-case")]
700pub enum Author {
701 /// An individual.
702 Person {
703 /// Family name, as the work spells it.
704 surname: String,
705 /// Given names, in order, each either written out (`Mary`) or already
706 /// an initial (`M.`). A renderer that wants initials derives them from
707 /// the first character either way, so recording the full name loses
708 /// nothing and recording only the initial costs nothing.
709 given: Vec<GivenName>,
710 },
711 /// A group, organisation or company — a legitimate author in its own right,
712 /// spelled out in full. It is not a person, so it is never reduced to
713 /// initials, and the distinction is a variant rather than a flag so that
714 /// no renderer can reach for the initials of `World Health Organization`.
715 Group(String),
716}
717
718impl Author {
719 /// The string a reference list alphabetises on: the surname of a person,
720 /// or the full name of a group.
721 #[must_use]
722 pub fn sort_key(&self) -> &str {
723 match self {
724 Self::Person { surname, .. } => surname,
725 Self::Group(name) => name,
726 }
727 }
728}
729
730/// A Digital Object Identifier, held bare (`10.3886/ICPSR36966.v1`).
731///
732/// A newtype for one reason: a DOI is rendered by prefixing
733/// `https://doi.org/`, and a record that had already stored the prefixed form
734/// would render `https://doi.org/https://doi.org/10.…`. [`Doi::new`] accepts
735/// either spelling and stores the bare one, so the prefix is applied exactly
736/// once no matter which form was written down.
737///
738/// # What is stored is the identifier, not a URL
739///
740/// The value held here is the **DOI name itself**, raw: `10.1234/a#b` is a DOI
741/// whose suffix contains a literal `#`. It is never the percent-encoded form.
742/// [`Doi::url`] applies that encoding on the way out, and [`Doi::new`] undoes it
743/// when the input arrived as a resolver URL, so the two are inverses and
744/// `Doi::new(d.url())` returns `d`.
745///
746/// Stating it that way round is what keeps the type coherent. The alternative —
747/// storing the encoded form — makes [`Doi::as_str`] not the identifier but a
748/// fragment of a URL, and leaves no way to tell a DOI containing a literal `%`
749/// from one whose `%` opens an escape. Recording what the thing *is* and
750/// transforming at the boundary is the same rule [`GivenName`] follows.
751#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
752#[serde(try_from = "String", into = "String")]
753pub struct Doi(String);
754
755/// `text` without `prefix`, comparing the prefix case-insensitively.
756///
757/// `str::get` rather than a slice, so a multi-byte character straddling the
758/// prefix length is a `None` rather than a panic.
759fn strip_prefix_ignoring_ascii_case<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
760 text.get(..prefix.len())
761 .filter(|head| head.eq_ignore_ascii_case(prefix))
762 .map(|_| &text[prefix.len()..])
763}
764
765/// Whether every character of `text` is one a reader can see and a URL can
766/// carry: an ASCII graphic character, `!` through `~`.
767///
768/// Every identifier this model hands to a renderer — a DOI, a URL — is printed
769/// as its own visible text *and* used as a link target, so the two have to be
770/// the same string in the reader's eye as well as in the byte stream.
771///
772/// This is an **allowlist**, and the inversion is the whole point of it. It
773/// replaced a denylist of whitespace, control and bidirectional characters,
774/// which is a rule that cannot be finished: Unicode holds far more invisible
775/// characters than a hand-written list will ever name — U+061C, the
776/// U+206A–U+206F block, U+00AD, U+2060, the U+E0020 tag characters — and every
777/// one missing from such a list is a link whose printed text is not where it
778/// goes. Two were found by review, one after the other, which is the shape of a
779/// rule that will keep producing a next one.
780///
781/// Inverting it puts the failure in the safe direction, which is the argument
782/// rather than tidiness. An incomplete **denylist** emits a citation that is not
783/// what it looks like, and does it silently. An incomplete **allowlist** refuses
784/// a citation somebody can then come and argue for, visibly. Preferring the
785/// second is this module's entire subject, so its guards should be shaped that
786/// way too.
787///
788/// This is the rule for a value that is **emitted exactly as recorded** — a
789/// `Locator::Url`, which becomes an `href` with no encoding step between the
790/// record and the page. Such a value has to be URL-safe and printable already,
791/// because nothing downstream will make it so.
792///
793/// The cost is real and worth stating plainly: a genuine IRI is refused and has
794/// to be recorded in its percent-encoded form. That narrows what is
795/// *recordable*, not what is correct, and it announces itself the moment
796/// somebody tries.
797///
798/// A DOI is deliberately **not** held to this rule — see `is_doi_name_char`.
799/// The difference is not an inconsistency but the reason the two exist
800/// separately: a DOI is encoded at the boundary by [`Doi::url`], so it can be
801/// recorded as the identifier itself, while a URL is not, so it cannot.
802/// # What this does and does not promise a consumer
803///
804/// It promises the value contains **only characters RFC 3986 permits in a
805/// URI**, and nothing invisible. In particular `"`, `<`, `>`, `\`, `^`,
806/// `` ` ``, `{`, `|` and `}` are excluded — so a locator cannot close a
807/// double-quoted HTML attribute or open a tag, which is how
808/// `https://example.invalid/a"onmouseover="…` became an `href` before this rule
809/// was narrowed. ASCII-graphic was the wrong bar: it admits all nine.
810///
811/// It does **not** promise the value is safe to interpolate into HTML
812/// unescaped, and nothing here should be read as saying so. `&` and `'` are
813/// legal URI characters and are allowed, so a consumer building markup must
814/// still escape for its own context — `&` always, and `'` if it quotes
815/// attributes with it. **Escaping is the consumer's duty.** What this rule buys
816/// is that the characters which make forgetting that duty catastrophic are not
817/// representable in the first place.
818#[must_use]
819pub fn is_printable_identifier(text: &str) -> bool {
820 !text.is_empty() && text.chars().all(is_uri_char) && percent_escapes_are_well_formed(text)
821}
822
823/// Whether every `%` in `text` introduces a complete `%XX` escape.
824///
825/// `%` is a legal URI character, so [`is_uri_char`] admits it — but only as the
826/// *introducer* of an escape, and whether it is one is a property of the three
827/// characters together rather than of the `%`. Without this,
828/// `https://example.invalid/a%ZZ` and a trailing `%` passed a predicate
829/// documenting that its value holds "only characters RFC 3986 permits in a URI",
830/// which was not true of them: a lone `%` makes the string not a URI at all.
831///
832/// The same shape as the calendar check on [`PublicationDate`] — an invariant
833/// enforced on a part while the composite went unasked — and the same answer.
834fn percent_escapes_are_well_formed(text: &str) -> bool {
835 let bytes = text.as_bytes();
836 let mut index = 0;
837 while index < bytes.len() {
838 if bytes[index] == b'%' {
839 match text.get(index + 1..index + 3) {
840 Some(hex) if hex.bytes().all(|byte| byte.is_ascii_hexdigit()) => index += 3,
841 _ => return false,
842 }
843 } else {
844 index += 1;
845 }
846 }
847 true
848}
849
850/// Whether `c` is a character RFC 3986 permits to appear in a URI.
851///
852/// Unreserved, sub-delims, gen-delims, and `%` for an escape — which is the
853/// whole of §2, stated positively. The nine ASCII graphics it leaves out
854/// (`"`, `<`, `>`, `\`, `^`, `` ` ``, `{`, `|`, `}`) are the ones RFC 3986
855/// excludes from a URI outright, and are exactly the ones that let a locator
856/// escape an HTML attribute.
857fn is_uri_char(c: char) -> bool {
858 c.is_ascii_alphanumeric()
859 || matches!(
860 c,
861 // Unreserved.
862 '-' | '.' | '_' | '~'
863 // Sub-delims.
864 | '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '='
865 // Gen-delims.
866 | ':' | '/' | '?' | '#' | '[' | ']' | '@'
867 // The escape introducer.
868 | '%'
869 )
870}
871
872/// Whether `c` may appear in a DOI name.
873///
874/// ASCII graphic characters, **or** any letter or digit of any script. So
875/// `10.1234/中文` is recordable, because it is a legal DOI name and refusing it
876/// would be this type inventing a rule the standard does not have.
877///
878/// This is **narrower than the standard**, and the doc used to overstate it by
879/// saying "any printable character from the Unicode Standard" — which admits
880/// non-ASCII punctuation this refuses, an em dash among them. The narrowing is
881/// deliberate and the reason is the shape of the rule, not the characters:
882/// "printable" has no allowlist spelling, only a denylist of the invisible, and
883/// this module has already spent a round learning that such a list cannot be
884/// finished. Letters and digits exclude the entire format category by
885/// construction. A DOI carrying an em dash is therefore refused — visibly, and
886/// arguably — rather than admitted by a rule that would also admit the next
887/// zero-width character nobody listed.
888///
889/// Wider than [`is_printable_identifier`] on purpose, and safe to be wider for a
890/// reason that is structural rather than a judgement call: a DOI is never
891/// emitted as recorded. It reaches a page only through [`Doi::url`], which
892/// percent-encodes everything outside RFC 3986's `pchar` — so whatever is
893/// recorded here, the rendered link is pure ASCII, visible, and decodes back to
894/// exactly this. A `Locator::Url` has no such boundary, which is why it keeps
895/// the narrower rule.
896///
897/// Still an allowlist. Letters and digits exclude the whole format category
898/// (`Cf`) — the zero-width set, the bidi controls, the tag characters — and
899/// every separator, so the characters that walked through the denylist this
900/// replaced are refused here by category rather than by enumeration.
901fn is_doi_name_char(c: char) -> bool {
902 c.is_ascii_graphic() || c.is_alphanumeric()
903}
904
905/// Reverse [`Doi::url`]'s escaping: `%XX` becomes the byte it names.
906///
907/// `None` for a truncated escape, a non-hexadecimal one, or bytes that are not
908/// valid UTF-8 once decoded — each of which makes the input not a form
909/// [`Doi::url`] could have produced, and so not one to guess at.
910fn percent_decode(text: &str) -> Option<String> {
911 let bytes = text.as_bytes();
912 let mut decoded = Vec::with_capacity(bytes.len());
913 let mut index = 0;
914 while index < bytes.len() {
915 if bytes[index] == b'%' {
916 let hex = text.get(index + 1..index + 3)?;
917 if !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
918 return None;
919 }
920 decoded.push(u8::from_str_radix(hex, 16).ok()?);
921 index += 3;
922 } else {
923 decoded.push(bytes[index]);
924 index += 1;
925 }
926 }
927 String::from_utf8(decoded).ok()
928}
929
930/// Whether `byte` may stand for itself inside the path of a resolver URL.
931///
932/// RFC 3986's `pchar` — unreserved, sub-delims, `:` and `@` — plus `/`, which
933/// separates the segments of a DOI suffix. An allowlist again, so a character
934/// nobody considered is percent-encoded rather than emitted raw.
935const fn is_url_path_safe(byte: u8) -> bool {
936 byte.is_ascii_alphanumeric()
937 || matches!(
938 byte,
939 // Unreserved.
940 b'-' | b'.' | b'_' | b'~'
941 // Sub-delims.
942 | b'!' | b'$' | b'&' | b'\'' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
943 // The two pchar additions, and the segment separator.
944 | b':' | b'@' | b'/'
945 )
946}
947
948/// A string that is not a DOI.
949#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
950#[error(
951 "not a DOI: {0:?} (expected `10.<digits>/<suffix>`, optionally behind a doi.org resolver URL or a `doi:` prefix)"
952)]
953pub struct NotADoi(pub String);
954
955impl Doi {
956 /// Parse a DOI, accepting the bare form, a `doi:` prefix, or a
957 /// `https://doi.org/` (or `http://`, or `dx.doi.org`) resolver URL.
958 ///
959 /// The shape checked is the one the DOI Handbook defines: a `10.` prefix, a
960 /// registrant code of digits (possibly dot-separated, as in
961 /// `10.1000.10/123`), a `/`, and a non-empty suffix **all of whose
962 /// characters are printable** (`is_doi_name_char`). That is deliberately
963 /// stricter than "starts with `10.` and contains a slash", because
964 /// everything this type accepts is rendered as a resolver link — and a link
965 /// that resolves to nothing is exactly the plausible-looking citation this
966 /// module exists to prevent. Beyond the shape it cannot go: whether a
967 /// well-formed DOI is *registered* is a question only the network answers,
968 /// and this crate has no network by construction.
969 ///
970 /// # Errors
971 ///
972 /// Returns [`NotADoi`] when what remains after the prefix is not that shape.
973 pub fn new(text: &str) -> Result<Self, NotADoi> {
974 let trimmed = text.trim();
975 let refused = || NotADoi(text.to_owned());
976
977 // Case-insensitively throughout: a URI scheme is case-insensitive by
978 // RFC 3986, and `DOI:10.1234/x` is how plenty of publishers print one.
979 // Matching exactly would send those down the "not a DOI at all" path,
980 // where they are rejected for a reason that is not true of them.
981 //
982 // A **resolver URL carries the DOI percent-encoded**, so stripping the
983 // prefix leaves the encoded form and it has to be decoded back to the
984 // identifier. `doi:` and the bare form are the identifier already, and
985 // are taken exactly as written. Without this split the two input forms
986 // disagree: `https://doi.org/10.1234/a%23b` and `doi:10.1234/a#b` name
987 // one DOI, and only one of them would round-trip through `url`.
988 let bare = match [
989 "https://doi.org/",
990 "http://doi.org/",
991 "https://dx.doi.org/",
992 "http://dx.doi.org/",
993 ]
994 .iter()
995 .find_map(|prefix| strip_prefix_ignoring_ascii_case(trimmed, prefix))
996 {
997 Some(encoded) => {
998 // In a URL, `?` opens a query and `#` opens a fragment, and a
999 // fragment is never sent to the server at all. So they are
1000 // *delimiters* here, not characters of the name:
1001 // `https://doi.org/10.1234/a#b` asks the resolver for
1002 // `10.1234/a`, and storing `10.1234/a#b` would record a DOI
1003 // nobody requested and re-emit it as `…/a%23b` — a different
1004 // record again. Truncating at the first of either is what the
1005 // resolver itself does.
1006 //
1007 // Note this makes the two input forms deliberately *disagree*:
1008 // the bare `10.1234/a#b` is a DOI name whose suffix contains a
1009 // literal `#`, while the URL form names `10.1234/a`. That is
1010 // correct — they are different statements — and it is the reason
1011 // the decode happens only on the URL path.
1012 let addressed = encoded.split(['?', '#']).next().unwrap_or_default();
1013 percent_decode(addressed).ok_or_else(refused)?
1014 }
1015 None => strip_prefix_ignoring_ascii_case(trimmed, "doi:")
1016 .unwrap_or(trimmed)
1017 .to_owned(),
1018 };
1019
1020 // The DOI Handbook lets a DOI name incorporate any printable character
1021 // from the Unicode Standard, and this does not second-guess that beyond
1022 // the one question it must ask: can every character of it be printed?
1023 // `is_doi_name_char` is an allowlist, so the next exotic code point is
1024 // refused rather than discovered by a reviewer.
1025 let well_formed = {
1026 let Some(rest) = bare.strip_prefix("10.") else {
1027 return Err(refused());
1028 };
1029 let Some((registrant, suffix)) = rest.split_once('/') else {
1030 return Err(refused());
1031 };
1032 // Digits and dots, and deliberately **no minimum length**. Registrant
1033 // codes are in practice four digits or more (`10.1000` is the
1034 // lowest assigned), but that is how they have been handed out, not
1035 // a rule of the syntax: neither ISO 26324 nor ANSI/NISO Z39.84
1036 // states a digit count, Crossref's own documentation says members
1037 // create only the suffix and describes no prefix structure, and the
1038 // usual secondary sources say the prefix "*usually*" takes the form
1039 // `10.NNNN`. Enforcing a convention as though it were the standard
1040 // is how a type comes to refuse a registered identifier, which is a
1041 // worse failure than accepting an unassigned one — and whether a
1042 // well-formed DOI is *registered* is a question only the network
1043 // answers, which this crate does not have.
1044 let registrant_is_numeric = registrant
1045 .split('.')
1046 .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()));
1047 registrant_is_numeric && !suffix.is_empty() && suffix.chars().all(is_doi_name_char)
1048 };
1049 if well_formed {
1050 Ok(Self(bare))
1051 } else {
1052 Err(refused())
1053 }
1054 }
1055
1056 /// The bare DOI, with no resolver prefix.
1057 #[must_use]
1058 pub fn as_str(&self) -> &str {
1059 &self.0
1060 }
1061
1062 /// The DOI as APA renders it: `https://doi.org/10.…`.
1063 ///
1064 /// The identifier is percent-encoded on the way in, because a DOI may
1065 /// legitimately contain characters that mean something else inside a URL.
1066 /// The sharpest is `#`: concatenated raw, `10.1234/a#b` becomes
1067 /// `https://doi.org/10.1234/a#b`, whose `#b` is an HTTP fragment and is
1068 /// never sent to the resolver — so the link retrieves `10.1234/a`, *a
1069 /// different record*, silently and with every appearance of working. `?`
1070 /// opens a query string and does the same; a bare `%` invalidates the escape
1071 /// sequence it looks like.
1072 ///
1073 /// Encoded rather than refused, and the reason is evidence rather than
1074 /// taste: `10.1002/(SICI)1097-0258(19970815)16:15<1707::AID-SIM605>3.0.CO;2-Y`
1075 /// is a real registered DOI, and `<`, `>`, `(`, `;` and `:` are ordinary in
1076 /// the wild. A suffix rule narrow enough to be URL-safe by construction would
1077 /// refuse identifiers that exist, which is a worse failure than encoding
1078 /// them. [`Doi::as_str`] still returns the DOI exactly as recorded — this is
1079 /// a transport encoding applied where the transport is, the same kind of
1080 /// operation as applying the resolver prefix, and for the same reason it is
1081 /// applied exactly once.
1082 ///
1083 /// What passes through unescaped is RFC 3986's `pchar` (`is_url_path_safe`),
1084 /// plus `/`; everything else
1085 /// becomes `%XX`. Byte by byte, so a multi-byte character is encoded as the
1086 /// bytes a URL actually carries.
1087 #[must_use]
1088 pub fn url(&self) -> String {
1089 let mut url = String::from("https://doi.org/");
1090 for byte in self.0.bytes() {
1091 if is_url_path_safe(byte) {
1092 url.push(char::from(byte));
1093 } else {
1094 const HEX: &[u8; 16] = b"0123456789ABCDEF";
1095 url.push('%');
1096 url.push(char::from(HEX[usize::from(byte >> 4)]));
1097 url.push(char::from(HEX[usize::from(byte & 0x0F)]));
1098 }
1099 }
1100 url
1101 }
1102}
1103
1104impl TryFrom<String> for Doi {
1105 type Error = NotADoi;
1106
1107 fn try_from(text: String) -> Result<Self, Self::Error> {
1108 Self::new(&text)
1109 }
1110}
1111
1112impl From<Doi> for String {
1113 fn from(doi: Doi) -> Self {
1114 doi.0
1115 }
1116}
1117
1118impl fmt::Display for Doi {
1119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1120 f.write_str(&self.0)
1121 }
1122}
1123
1124/// Where a work can be found online.
1125///
1126/// [`Locator::Both`] exists so that holding a URL *as well as* a DOI is
1127/// recordable without either being lost. Which one a given style prints is that
1128/// style's decision, not the record's — APA prints the DOI alone.
1129///
1130/// Deliberately not `#[non_exhaustive]`. Other persistent identifiers exist
1131/// (ISBN, Handle, arXiv), and each would have to be *routed* by every
1132/// formatter: which one wins when several are present is a style rule, not a
1133/// default. Closing the set is what makes each formatter answer that question
1134/// instead of falling through a wildcard and printing nothing.
1135#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1136#[serde(rename_all = "kebab-case")]
1137pub enum Locator {
1138 /// A DOI.
1139 Doi(Doi),
1140 /// A URL.
1141 Url(String),
1142 /// A DOI and a URL, both recorded.
1143 Both {
1144 /// The DOI.
1145 doi: Doi,
1146 /// The URL.
1147 url: String,
1148 },
1149}
1150
1151/// What kind of thing a work is.
1152///
1153/// Deliberately a short list of **standalone** works, because those are the
1154/// ones this phase can render correctly. A journal article or a book chapter is
1155/// a work inside a greater whole, and citing one needs a second model — the
1156/// container, its editors, the volume, the issue, the page range. Rather than
1157/// carry half of that and emit a reference with the container guessed at, the
1158/// kind is simply not spellable yet. Refusing at the type is the same rule this
1159/// module applies everywhere else, one level up.
1160///
1161/// Deliberately not `#[non_exhaustive]`, and this is the enum here most likely
1162/// to gain a variant. That is the reason rather than an objection: the kind
1163/// decides whether a bracketed descriptor is required, whether a locator is
1164/// required, and whether the title is italicised. A wildcard arm would answer
1165/// all three for a journal article the way it answers them for a book, and
1166/// quietly emit a wrong reference — so a new kind has to break the build in the
1167/// renderer until somebody says what it is.
1168#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1169// Kebab-case on the wire so the serialised token is the one `WorkKind::as_str`
1170// documents. They were allowed to disagree once, and a token that two parts of
1171// one crate spell differently is a format nobody can rely on.
1172#[serde(rename_all = "kebab-case")]
1173pub enum WorkKind {
1174 /// A standalone document: a book, a report, a specification, a standard.
1175 Document,
1176 /// Computer software.
1177 Software,
1178 /// A data set.
1179 DataSet,
1180 /// A fact sheet.
1181 FactSheet,
1182 /// A page on a website.
1183 WebPage,
1184}
1185
1186impl WorkKind {
1187 /// A stable lowercase token for this kind, for serialised reports and
1188 /// diagnostics.
1189 #[must_use]
1190 pub fn as_str(self) -> &'static str {
1191 match self {
1192 Self::Document => "document",
1193 Self::Software => "software",
1194 Self::DataSet => "data-set",
1195 Self::FactSheet => "fact-sheet",
1196 Self::WebPage => "web-page",
1197 }
1198 }
1199}
1200
1201/// A citable external work.
1202///
1203/// Every field a renderer consults is either a plain value that must be present
1204/// (the identifier, the kind, the title, the stability) or an [`Attested`],
1205/// which is how a field says "nobody has checked" without that being
1206/// indistinguishable from "there is nothing to check".
1207///
1208/// The record carries no opinion about whether it is *complete*. Completeness
1209/// is a property of a record **and a citation style together** — APA insists on
1210/// a bracketed descriptor for software that another style would not ask for —
1211/// so it is decided by the renderer, which is where the style's rules are.
1212///
1213/// The derived [`Ord`] is **structural** — field order, so [`Reference::id`]
1214/// first — and exists to give [`Reference::list_order`] a last resort that is
1215/// total over the whole record. It is not the order a reference list is printed
1216/// in; sorting a slice with it directly gives id order, which is nobody's
1217/// bibliography.
1218#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1219pub struct Reference {
1220 /// A stable identifier for this work within a reference register. It never
1221 /// appears in rendered output; it is what a citation points at, what a
1222 /// refusal names, and the final tiebreak that makes list ordering total.
1223 pub id: String,
1224 /// What kind of thing the work is.
1225 pub kind: WorkKind,
1226 /// The authors, in the order the work presents them. That order is the
1227 /// work's own and is never sorted: authorship order carries meaning.
1228 pub authors: Vec<Author>,
1229 /// When the work was published. [`Attested::AbsentFromWork`] is a dateless
1230 /// work — APA's `n.d.` — and [`Attested::Unknown`] is a date nobody has
1231 /// looked for, which no renderer may print.
1232 pub published: Attested<PublicationDate>,
1233 /// The title, in the case it should be printed in.
1234 ///
1235 /// Stored ready to print. Recasing a title needs to know which words are
1236 /// proper nouns, and a renderer guessing at that is a renderer changing
1237 /// what somebody wrote down — so the register records sentence case and
1238 /// nothing downstream touches it.
1239 pub title: String,
1240 /// The version, edition or release designation, if the work has one,
1241 /// recorded bare — `3.3.070`, not `Version 3.3.070` and not `v3.3.070`.
1242 /// A renderer supplies whatever word its style puts in front, and one that
1243 /// found the word already there would print it twice.
1244 pub version: Attested<String>,
1245 /// A bracketed description of a non-standard work — `Computer software`,
1246 /// `Data set`, `Fact sheet` — stored **without** its brackets.
1247 ///
1248 /// This is a judgement, not a fact read off the work, and it is
1249 /// deliberately not derivable from [`Reference::kind`]: inferring
1250 /// `Computer software` from `WorkKind::Software` would be the renderer
1251 /// inventing the one element APA added to stop readers mistaking one kind
1252 /// of source for another.
1253 pub descriptor: Attested<String>,
1254 /// The publisher, repository or site responsible for making the work
1255 /// available.
1256 pub publisher: Attested<String>,
1257 /// Where the work can be found.
1258 pub locator: Attested<Locator>,
1259 /// Whether the work holds still, carrying the retrieval date when it does
1260 /// not.
1261 pub stability: Stability,
1262}
1263
1264impl Reference {
1265 /// A record with the fields that have no honest default left
1266 /// [`Attested::Unknown`], and no authors.
1267 ///
1268 /// Such a record **refuses**: that is the point of it. Construction cannot
1269 /// produce something citable by accident, and every field that reaches
1270 /// output has to be filled in deliberately.
1271 #[must_use]
1272 pub fn new(
1273 id: impl Into<String>,
1274 kind: WorkKind,
1275 title: impl Into<String>,
1276 stability: Stability,
1277 ) -> Self {
1278 Self {
1279 id: id.into(),
1280 kind,
1281 authors: Vec::new(),
1282 published: Attested::Unknown,
1283 title: title.into(),
1284 version: Attested::Unknown,
1285 descriptor: Attested::Unknown,
1286 publisher: Attested::Unknown,
1287 locator: Attested::Unknown,
1288 stability,
1289 }
1290 }
1291
1292 /// The total order a reference list is printed in.
1293 ///
1294 /// APA §9.47 is more than "alphabetical by surname", and the parts people
1295 /// leave out are the parts that make two correct-looking lists disagree:
1296 ///
1297 /// - a one-author entry precedes a multi-author entry beginning with the
1298 /// same surname;
1299 /// - entries sharing a first author are ordered by the *second* author's
1300 /// surname, and so on down the list;
1301 /// - two different people with the same surname are ordered by their
1302 /// initials, before date is consulted at all — `Smith, A.` precedes
1303 /// `Smith, T.`, whatever year either published;
1304 /// - only then does date break the tie, earliest first, with an undated
1305 /// work before any dated one.
1306 ///
1307 /// Comparing the **whole** author list, element by element, gets the first
1308 /// two for free: `["salas"]` is a prefix of `["salas", "d'agostino"]` and
1309 /// so sorts before it, and `["salas", "a"]` sorts before `["salas", "b"]`.
1310 ///
1311 /// The keys, in order:
1312 ///
1313 /// 1. every author, in the order the work lists them — surname then
1314 /// *initials as rendered*, folded **letter by letter**: capitals ignored,
1315 /// and spaces and punctuation dropped, which is what APA's "letter by
1316 /// letter" means literally. `Olsen` therefore precedes `O'Malley`
1317 /// precedes `O'Neil`. Rendered initials rather than recorded given names,
1318 /// so that nothing invisible on the page can decide the order of the
1319 /// page;
1320 /// 2. the same list case-sensitively, so the fold in step 1 never decides a
1321 /// tie by accident of iteration order;
1322 /// 3. the full publication date — year, then month, then day, so two works
1323 /// from one year are not left to be separated by their titles. An
1324 /// undated work sorts first (APA puts `n.d.` before any year for the
1325 /// same author) and a record whose date nobody has looked up sorts last;
1326 /// such a record never reaches a rendered list, but the order still has
1327 /// to be defined for it. A year-only date precedes a more precise one in
1328 /// the same year, because that is the only placement that does not
1329 /// invent a month for it;
1330 /// 4. title, folded the same way then compared as written — trimmed, like
1331 /// the author keys, because the renderer trims both and whitespace nobody
1332 /// can see must not decide an order somebody reads. Folded the same way
1333 /// because APA alphabetises a title letter by letter too, and one
1334 /// function applied to both is what keeps them from disagreeing;
1335 /// 5. [`Reference::id`];
1336 /// 6. the record itself, structurally.
1337 ///
1338 /// Key 6 is what makes the order genuinely **total** rather than total
1339 /// only where ids happen to be unique. `id` is a public `String` and
1340 /// nothing enforces uniqueness, so two records could agree on keys 1–5 and
1341 /// still differ — in a publisher, say. Without a last resort they would
1342 /// compare `Equal`, and a stable sort would then order them by the order
1343 /// they arrived in, which is exactly the input-order dependence the
1344 /// byte-reproducibility rule forbids.
1345 ///
1346 /// Deliberately a named function rather than *the* `Ord` implementation:
1347 /// `Ord` must agree with `Eq`, and keys 1–5 are a strict subset of the
1348 /// fields `PartialEq` compares. The derived `Ord` on [`Reference`] — which
1349 /// key 6 uses — is structural and agrees with `Eq`; it is a tiebreak, not
1350 /// a reference-list order, and sorting with it directly gives id order.
1351 #[must_use]
1352 pub fn list_order(a: &Self, b: &Self) -> Ordering {
1353 fn title(reference: &Reference) -> &str {
1354 reference.title.trim()
1355 }
1356 // Surname **and** initials: a surname alone cannot separate two
1357 // different people who share one, and APA orders those by their
1358 // initials before it looks at the date.
1359 //
1360 // The **initials**, via `GivenName::initial`, and not the recorded given
1361 // names — because APA alphabetises a list by what the list prints, and
1362 // what it prints is `Smith, M.` whether the register learned `Mary` or
1363 // only `M.`. Keying on the recorded spelling let a difference the page
1364 // does not show decide an order the page does show: two entries reading
1365 // `Smith, M. (2020).` came out in an order no reader could account for,
1366 // and swapping one record's `Mary` for `M.` — no change to a single
1367 // rendered character — moved it. Two such entries are genuinely
1368 // indistinguishable to a reader, so they fall through to date, title and
1369 // id, which are things a reader can see.
1370 //
1371 // Trimmed, because the renderer trims: a leading space that no reader
1372 // can see must not decide where an entry lands either. `GivenName`
1373 // stores its trimmed form, so only the surname needs it here.
1374 let authors = |r: &Self| -> Vec<(String, Vec<String>)> {
1375 r.authors
1376 .iter()
1377 .map(|author| {
1378 let given = match author {
1379 Author::Person { given, .. } => {
1380 given.iter().map(GivenName::initial).collect()
1381 }
1382 Author::Group(_) => Vec::new(),
1383 };
1384 (author.sort_key().trim().to_owned(), given)
1385 })
1386 .collect()
1387 };
1388 // Letter by letter, which APA means literally: spaces and punctuation are
1389 // ignored, so `Olsen` precedes `O'Malley` precedes `O'Neil` — Ol, OM,
1390 // ON. Keeping the apostrophe in the key sorted all three wrongly, since
1391 // `'` sorts below every letter and put both O'-names above `Olsen`.
1392 // Dropping the punctuation from the *fold* only: key 2 below still
1393 // compares the names as written, so two people who differ only in
1394 // punctuation are still ordered, and deterministically.
1395 let fold = |name: &str| -> String {
1396 name.chars()
1397 .filter(|c| c.is_alphanumeric())
1398 .flat_map(char::to_lowercase)
1399 .collect()
1400 };
1401 let folded = |names: &[(String, Vec<String>)]| -> Vec<(String, Vec<String>)> {
1402 names
1403 .iter()
1404 .map(|(surname, given)| {
1405 (fold(surname), given.iter().map(|name| fold(name)).collect())
1406 })
1407 .collect()
1408 };
1409 let (left, right) = (authors(a), authors(b));
1410 // `Unknown` last, `AbsentFromWork` (n.d.) first, known dates between —
1411 // and a known date compares on all the precision it has.
1412 let date = |r: &Self| match &r.published {
1413 Attested::AbsentFromWork => (0_u8, 0_i32, 0_u8, 0_u8),
1414 Attested::Known(published) => {
1415 let (year, month, day) = match published {
1416 PublicationDate::Year(year) => (year.get(), 0, 0),
1417 PublicationDate::YearMonth { year, month } => (year.get(), month.number(), 0),
1418 PublicationDate::Full { year, month, day } => {
1419 (year.get(), month.number(), day.get())
1420 }
1421 };
1422 (1, year, month, day)
1423 }
1424 Attested::Unknown => (2, 0, 0, 0),
1425 };
1426 folded(&left)
1427 .cmp(&folded(&right))
1428 .then_with(|| left.cmp(&right))
1429 .then_with(|| date(a).cmp(&date(b)))
1430 .then_with(|| fold(title(a)).cmp(&fold(title(b))))
1431 .then_with(|| title(a).cmp(title(b)))
1432 .then_with(|| a.id.cmp(&b.id))
1433 .then_with(|| a.cmp(b))
1434 }
1435}
1436
1437#[cfg(test)]
1438mod tests {
1439 use super::{
1440 AccessDate, Attested, Author, Day, Doi, GivenName, Month, Ordering, PublicationDate,
1441 Reference, Stability, WorkKind, Year, is_printable_identifier,
1442 };
1443
1444 fn year(year: i32) -> Year {
1445 Year::new(year).expect("a valid year")
1446 }
1447
1448 fn given_name(name: &str) -> GivenName {
1449 GivenName::new(name).expect("a valid given name")
1450 }
1451
1452 fn person(surname: &str) -> Author {
1453 Author::Person {
1454 surname: surname.to_owned(),
1455 given: vec![given_name("A")],
1456 }
1457 }
1458
1459 fn dated(id: &str, surname: &str, published: i32) -> Reference {
1460 let mut reference = Reference::new(
1461 id,
1462 WorkKind::Document,
1463 "A title",
1464 Stability::FixedOrArchived,
1465 );
1466 reference.authors = vec![person(surname)];
1467 reference.published = Attested::Known(PublicationDate::Year(year(published)));
1468 reference
1469 }
1470
1471 #[test]
1472 fn a_fresh_record_knows_nothing_it_was_not_told() {
1473 let reference = Reference::new("r", WorkKind::Software, "T", Stability::FixedOrArchived);
1474 // Every one of these is `Unknown`, not `AbsentFromWork`: nobody has
1475 // looked. A renderer must refuse on all of them.
1476 assert!(reference.published.is_unknown());
1477 assert!(reference.version.is_unknown());
1478 assert!(reference.descriptor.is_unknown());
1479 assert!(reference.publisher.is_unknown());
1480 assert!(reference.locator.is_unknown());
1481 assert!(reference.authors.is_empty());
1482 }
1483
1484 #[test]
1485 fn the_default_is_the_state_that_refuses() {
1486 let attested: Attested<String> = Attested::default();
1487 assert!(
1488 attested.is_unknown(),
1489 "a field nobody has filled in must default to unresearched, never to absent"
1490 );
1491 }
1492
1493 #[test]
1494 fn absent_and_unknown_are_distinct_on_the_wire() {
1495 // A register is a file. If these two round-tripped through the same
1496 // token, the distinction the whole module rests on would survive only
1497 // until somebody saved.
1498 let absent: Attested<String> = Attested::AbsentFromWork;
1499 let unknown: Attested<String> = Attested::Unknown;
1500 let absent_json = serde_json::to_string(&absent).expect("serialize");
1501 let unknown_json = serde_json::to_string(&unknown).expect("serialize");
1502 assert_ne!(absent_json, unknown_json);
1503 assert_eq!(absent_json, "\"absent-from-work\"");
1504 assert_eq!(unknown_json, "\"unknown\"");
1505 let back: Attested<String> = serde_json::from_str(&absent_json).expect("deserialize");
1506 assert_eq!(back, Attested::AbsentFromWork);
1507 }
1508
1509 #[test]
1510 fn a_doi_is_stored_bare_however_it_was_written() {
1511 let bare = Doi::new("10.3886/ICPSR36966.v1").expect("bare");
1512 assert_eq!(bare.as_str(), "10.3886/ICPSR36966.v1");
1513 assert_eq!(bare.url(), "https://doi.org/10.3886/ICPSR36966.v1");
1514 for spelling in [
1515 "https://doi.org/10.3886/ICPSR36966.v1",
1516 "http://doi.org/10.3886/ICPSR36966.v1",
1517 "https://dx.doi.org/10.3886/ICPSR36966.v1",
1518 "doi:10.3886/ICPSR36966.v1",
1519 " 10.3886/ICPSR36966.v1 ",
1520 // A URI scheme is case-insensitive, and publishers print `DOI:`
1521 // both ways. Rejecting these would refuse a real DOI for a reason
1522 // that is not true of it.
1523 "HTTPS://doi.org/10.3886/ICPSR36966.v1",
1524 "DOI:10.3886/ICPSR36966.v1",
1525 "Doi:10.3886/ICPSR36966.v1",
1526 ] {
1527 let parsed = Doi::new(spelling).expect("parses");
1528 assert_eq!(
1529 parsed.url(),
1530 "https://doi.org/10.3886/ICPSR36966.v1",
1531 "the resolver prefix must be applied exactly once, for {spelling:?}"
1532 );
1533 }
1534 }
1535
1536 #[test]
1537 fn a_non_doi_is_refused_rather_than_prefixed() {
1538 for text in [
1539 "",
1540 "https://example.invalid/paper",
1541 "10.no-slash",
1542 "not a doi",
1543 // A `10.` and a slash is not enough: everything that parses here is
1544 // rendered as a resolver link, so these would publish a dead link
1545 // dressed as a citation.
1546 "10./suffix",
1547 "10.foo/suffix",
1548 "10.1037/",
1549 ] {
1550 assert!(Doi::new(text).is_err(), "{text:?} is not a DOI");
1551 }
1552 // …while a dot-separated numeric registrant is legitimate, and is the
1553 // reason the check is "digits and dots" rather than "digits".
1554 assert!(Doi::new("10.1000.10/123").is_ok());
1555 assert!(Doi::new("10.10.37/x").is_ok());
1556 }
1557
1558 #[test]
1559 fn an_impossible_day_cannot_be_recorded() {
1560 assert!(Day::new(0).is_none());
1561 assert!(Day::new(32).is_none());
1562 assert_eq!(Day::new(1).map(Day::get), Some(1));
1563 assert_eq!(Day::new(31).map(Day::get), Some(31));
1564 // …including on the way back in from a file.
1565 let refused: Result<Day, _> = serde_json::from_str("0");
1566 assert!(refused.is_err(), "deserialisation must validate too");
1567 }
1568
1569 #[test]
1570 fn a_date_carries_its_own_precision() {
1571 let day = Day::new(27).expect("valid");
1572 assert_eq!(PublicationDate::Year(year(2026)).year().get(), 2026);
1573 assert_eq!(
1574 PublicationDate::YearMonth {
1575 year: year(2026),
1576 month: Month::August
1577 }
1578 .year()
1579 .get(),
1580 2026
1581 );
1582 assert_eq!(
1583 PublicationDate::Full {
1584 year: year(2026),
1585 month: Month::August,
1586 day
1587 }
1588 .year()
1589 .get(),
1590 2026
1591 );
1592 assert_eq!(Month::August.name(), "August");
1593 assert_eq!(Month::from_number(8), Some(Month::August));
1594 assert_eq!(Month::from_number(0), None);
1595 assert_eq!(Month::from_number(13), None);
1596 assert_eq!(Month::December.number(), 12);
1597 }
1598
1599 #[test]
1600 fn a_retrieval_date_exists_only_where_apa_asks_for_one() {
1601 // Not an assertion about behaviour — an assertion that the other
1602 // combinations do not typecheck. `FixedOrArchived` has no field to put
1603 // a date in, and `UnarchivedAndChanging` cannot be built without one.
1604 let changing = Stability::UnarchivedAndChanging {
1605 retrieved: AccessDate {
1606 year: year(2020),
1607 month: Month::January,
1608 day: Day::new(9).expect("valid"),
1609 },
1610 };
1611 match changing {
1612 Stability::UnarchivedAndChanging { retrieved } => {
1613 assert_eq!(retrieved.year.get(), 2020);
1614 assert_eq!(retrieved.month.name(), "January");
1615 assert_eq!(retrieved.day.get(), 9);
1616 }
1617 Stability::FixedOrArchived => unreachable!("constructed as changing"),
1618 }
1619 }
1620
1621 #[test]
1622 fn a_group_author_sorts_on_its_whole_name() {
1623 let group = Author::Group("World Health Organization".to_owned());
1624 assert_eq!(group.sort_key(), "World Health Organization");
1625 assert_eq!(person("Salas").sort_key(), "Salas");
1626 }
1627
1628 #[test]
1629 fn a_list_is_ordered_alphabetically_and_totally() {
1630 let mut refs = [
1631 dated("c", "salas", 2019),
1632 dated("a", "Zhang", 1999),
1633 dated("b", "Salas", 2020),
1634 dated("d", "Abbott", 2020),
1635 ];
1636 refs.sort_by(Reference::list_order);
1637 let order: Vec<&str> = refs.iter().map(|r| r.id.as_str()).collect();
1638 // `Salas`/`salas` tie on the case-insensitive key and are then split by
1639 // the case-sensitive one, so the order is defined rather than left to
1640 // whichever the sort happened to see first.
1641 assert_eq!(order, ["d", "b", "c", "a"]);
1642 }
1643
1644 #[test]
1645 fn an_undated_work_sorts_before_the_same_authors_dated_ones() {
1646 // The year passed here is discarded by the next line in both cases; it
1647 // is only a placeholder, and `Year` no longer has a spelling for zero.
1648 let mut undated = dated("u", "Salas", 1999);
1649 undated.published = Attested::AbsentFromWork;
1650 let mut unresearched = dated("x", "Salas", 1999);
1651 unresearched.published = Attested::Unknown;
1652 let mut refs = [dated("b", "Salas", 2020), unresearched, undated];
1653 refs.sort_by(Reference::list_order);
1654 let order: Vec<&str> = refs.iter().map(|r| r.id.as_str()).collect();
1655 assert_eq!(
1656 order,
1657 ["u", "b", "x"],
1658 "n.d. first, then years; a date nobody looked up is not a date and sorts last"
1659 );
1660 }
1661
1662 #[test]
1663 fn a_given_name_that_cannot_be_rendered_cannot_be_written_down() {
1664 // The formatter used to reduce given names to initials with a
1665 // `filter_map`, so a fragment with no initial in it was dropped and the
1666 // author rendered anyway — `Smith, M.` from a record holding two given
1667 // names, one of them blank. Each of these is one of those fragments, and
1668 // none of them is constructible now.
1669 for refused in ["", " ", "Jean--Paul", "-Paul", "Jean-", "-", " - "] {
1670 assert!(
1671 GivenName::new(refused).is_err(),
1672 "{refused:?} has a part with no initial in it, so it must not be recordable"
1673 );
1674 }
1675 // Not a spelling rule — a statement that the record has nowhere to put
1676 // these. APA prints them after the initials, and among the given names
1677 // they render as an invented middle initial: `Smith, J., Jr.` becomes
1678 // `Smith, J. J.`.
1679 for suffix in ["Jr", "Jr.", "jr.", "SR", "Sr.", "II", "III", "IV"] {
1680 assert!(
1681 GivenName::new(suffix).is_err(),
1682 "{suffix:?} is a generational suffix, and this record has no field for one"
1683 );
1684 }
1685 for accepted in [
1686 "Mary",
1687 "M.",
1688 "Jean-Paul",
1689 "Ibáñez",
1690 "N'Golo",
1691 "Iva",
1692 "Владимир",
1693 ] {
1694 assert!(GivenName::new(accepted).is_ok(), "{accepted:?}");
1695 }
1696 // Outer whitespace is trimmed, because the renderer trims and a leading
1697 // space nobody can see must not decide an order somebody reads. Nothing
1698 // inside the name is touched.
1699 assert_eq!(given_name(" Mary ").as_str(), "Mary");
1700 assert!(GivenName::new("Jean - Paul").is_err());
1701 // And a file cannot smuggle one past construction either.
1702 assert!(serde_json::from_str::<GivenName>("\"Jean--Paul\"").is_err());
1703 assert!(serde_json::from_str::<GivenName>("\"\"").is_err());
1704 assert_eq!(
1705 serde_json::from_str::<GivenName>("\"Mary\"").expect("valid"),
1706 given_name("Mary")
1707 );
1708 }
1709
1710 #[test]
1711 fn an_initial_is_derived_in_one_place_so_two_readers_cannot_disagree() {
1712 // `Reference::list_order` alphabetises on this and the formatter prints
1713 // it. Two copies of the derivation is how the printed order and the
1714 // sorted order came apart.
1715 for (recorded, initial) in [
1716 ("Mary", "M."),
1717 ("M.", "M."),
1718 ("Jean-Paul", "J.-P."),
1719 ("ibáñez", "I."),
1720 ] {
1721 assert_eq!(given_name(recorded).initial(), initial, "{recorded:?}");
1722 }
1723 }
1724
1725 #[test]
1726 fn two_authors_the_page_prints_alike_are_not_ordered_by_what_it_hides() {
1727 // `Author` permits either `Mary` or `M.`, and both render as `M.` — so
1728 // these two entries are the same author, spelled two ways, and a reader
1729 // sees `Smith, M.` twice. Ordering on the *recorded* spelling let `M.`
1730 // precede `Mary` and put `B title` above `A title`, which is an order
1731 // nobody reading the page could account for. The key is the rendered
1732 // initial, so the tie falls through to title, which a reader can see.
1733 let mut spelled_out = dated("spelled-out", "Smith", 2020);
1734 spelled_out.authors = vec![Author::Person {
1735 surname: "Smith".to_owned(),
1736 given: vec![given_name("Mary")],
1737 }];
1738 spelled_out.title = "A title".to_owned();
1739 let mut initialled = dated("initialled", "Smith", 2020);
1740 initialled.authors = vec![Author::Person {
1741 surname: "Smith".to_owned(),
1742 given: vec![given_name("M.")],
1743 }];
1744 initialled.title = "B title".to_owned();
1745
1746 let mut refs = [initialled, spelled_out];
1747 refs.sort_by(Reference::list_order);
1748 assert_eq!(
1749 refs.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
1750 ["spelled-out", "initialled"],
1751 "the title decides, because it is the only difference the page shows"
1752 );
1753 // …and a genuine difference of initials still outranks the date, which
1754 // is the rule this must not have broken on the way past.
1755 let mut anne = dated("anne", "Smith", 2020);
1756 anne.authors = vec![Author::Person {
1757 surname: "Smith".to_owned(),
1758 given: vec![given_name("Anne")],
1759 }];
1760 let mut tom = dated("tom", "Smith", 1990);
1761 tom.authors = vec![Author::Person {
1762 surname: "Smith".to_owned(),
1763 given: vec![given_name("Tom")],
1764 }];
1765 let mut people = [tom, anne];
1766 people.sort_by(Reference::list_order);
1767 assert_eq!(
1768 people.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
1769 ["anne", "tom"]
1770 );
1771 }
1772
1773 #[test]
1774 fn a_year_that_is_not_a_year_cannot_be_written_down() {
1775 // A bare `i32` let these through `serde`, and the formatter rendered
1776 // them as `(0)` and `(-1, January 2)` — strings that are not dates,
1777 // printed with a date's authority. Year zero is not a year in the
1778 // numbering APA's readers use, and a negative year is BCE, which APA
1779 // writes `400 B.C.E.` and never `-400`.
1780 for refused in [0, -1, -400, i32::MIN] {
1781 assert!(Year::new(refused).is_none(), "{refused}");
1782 assert!(
1783 serde_json::from_str::<Year>(&refused.to_string()).is_err(),
1784 "deserialisation must validate too: {refused}"
1785 );
1786 }
1787 for accepted in [1, 1899, 2026, i32::MAX] {
1788 assert_eq!(Year::new(accepted).expect("a valid year").get(), accepted);
1789 }
1790 // No upper bound, deliberately: a forthcoming work is a real thing to
1791 // record, and separating one from a typo needs a clock this crate does
1792 // not have and must not acquire.
1793 assert!(Year::new(2999).is_some());
1794 }
1795
1796 #[test]
1797 fn an_identifier_cannot_carry_a_character_a_reader_cannot_see() {
1798 // Everything that parses here is rendered as a resolver link whose
1799 // visible text *is* its target, so a suffix carrying a newline is a
1800 // citation printed across two lines and resolving to neither, and a bidi
1801 // override is a link that reads as one thing and resolves to another.
1802 for hidden in [
1803 "10.1234/foo\nbar",
1804 "10.1234/foo bar",
1805 "10.1234/a\tb",
1806 "10.1234/foo\u{7}bar",
1807 "10.1234/foo\u{202e}bar",
1808 "10.1234/foo\u{200b}bar",
1809 "10.1234/foo\u{feff}bar",
1810 ] {
1811 assert!(
1812 Doi::new(hidden).is_err(),
1813 "{hidden:?} would become a link that is not what it looks like"
1814 );
1815 assert!(
1816 serde_json::from_str::<Doi>(&serde_json::to_string(hidden).expect("json")).is_err(),
1817 "deserialisation must validate too: {hidden:?}"
1818 );
1819 }
1820 // The ordinary shapes are untouched — this is not a DOI grammar, and a
1821 // suffix is allowed to be almost anything that can be printed.
1822 for fine in [
1823 "10.3886/ICPSR36966.v1",
1824 "10.1037/abc123",
1825 "10.1000.10/123",
1826 "10.1234/a(b)c;d",
1827 ] {
1828 assert!(Doi::new(fine).is_ok(), "{fine:?}");
1829 }
1830 assert!(is_printable_identifier("https://example.org/a-b"));
1831 assert!(!is_printable_identifier("https://example.org/a b"));
1832 }
1833
1834 /// Code points worth sweeping a guard against: the ASCII block, Latin-1
1835 /// (U+00AD SOFT HYPHEN), combining diacritics, the Arabic format controls
1836 /// (U+061C), general punctuation in full (U+2000–U+206F — every Unicode
1837 /// space, the zero-width set, the bidi embeddings, overrides and isolates,
1838 /// the word joiner, and the deprecated U+206A–U+206F block), the ideographic
1839 /// space, the byte-order mark, interlinear annotation, and the plane-14 tag
1840 /// characters. Two of these blocks held the characters that walked through a
1841 /// hand-written denylist; sweeping them is how a guard stops being a list of
1842 /// what somebody happened to think of.
1843 fn interesting_code_points() -> impl Iterator<Item = char> {
1844 [
1845 0x0000..=0x00FF_u32,
1846 0x0300..=0x036F,
1847 0x0590..=0x0620,
1848 0x2000..=0x2070,
1849 0x3000..=0x3002,
1850 0xFEFF..=0xFEFF,
1851 0xFFF9..=0xFFFC,
1852 0xE0000..=0xE0080,
1853 ]
1854 .into_iter()
1855 .flatten()
1856 .filter_map(char::from_u32)
1857 }
1858
1859 #[test]
1860 fn a_given_name_is_accepted_whole_or_not_at_all() {
1861 // The invariant, rather than the four inputs review happened to name:
1862 // for *any* code point, putting it inside a name either refuses the name
1863 // or renders it losslessly. What must never happen is the third outcome
1864 // — accepted, and rendered with part of it gone — which is how both
1865 // `["Mary", ""]` and `"Mary Ann"` got through.
1866 let mut accepted = 0_u32;
1867 for c in interesting_code_points() {
1868 let candidate = format!("Ma{c}ry");
1869 let Ok(name) = GivenName::new(&candidate) else {
1870 continue;
1871 };
1872 accepted += 1;
1873 assert_eq!(
1874 name.as_str(),
1875 candidate,
1876 "U+{:04X} was accepted, so it must be stored exactly",
1877 c as u32
1878 );
1879 // One initial per part, always. A separator that slipped through
1880 // the allowlist would show up here as a name with more words than
1881 // the rendering has initials — which is the drop, stated as a count.
1882 // The hyphen itself is in the sweep and legitimately makes two
1883 // parts, so the rule is a ratio rather than a constant.
1884 assert_eq!(
1885 name.initial().matches('.').count(),
1886 candidate.split('-').count(),
1887 "U+{:04X}: {candidate:?} rendered {:?}, losing a part",
1888 c as u32,
1889 name.initial()
1890 );
1891 assert!(
1892 !candidate.chars().any(char::is_whitespace),
1893 "U+{:04X} is whitespace and must not be inside one name",
1894 c as u32
1895 );
1896 // Two parts in, two initials out — the hyphen case, swept the same
1897 // way, since that is where the empty-part drop lived.
1898 let joined = format!("Ma{c}ry-Jo");
1899 let hyphenated = GivenName::new(&joined).expect("a valid given name");
1900 assert_eq!(
1901 hyphenated.initial().matches('.').count(),
1902 joined.split('-').count(),
1903 "U+{:04X}: {joined:?} rendered {:?}, losing a part",
1904 c as u32,
1905 hyphenated.initial()
1906 );
1907 }
1908 // Not vacuous: the sweep really does accept letters and marks, so this
1909 // is a test of an allowlist rather than of a `return false`.
1910 assert!(
1911 accepted > 100,
1912 "the sweep accepted only {accepted} code points — the allowlist cannot be that narrow"
1913 );
1914 }
1915
1916 #[test]
1917 fn a_doi_is_accepted_whole_or_not_at_all() {
1918 // The same invariant for the identifier that becomes a link: any code
1919 // point either refuses the DOI, or survives into a URL that still names
1920 // the same record. Nothing may be accepted and then quietly mean
1921 // something else — which is what `#` did, by becoming a fragment the
1922 // resolver never sees.
1923 let mut accepted = 0_u32;
1924 for c in interesting_code_points() {
1925 let candidate = format!("10.1234/a{c}b");
1926 let Ok(doi) = Doi::new(&candidate) else {
1927 continue;
1928 };
1929 accepted += 1;
1930 assert_eq!(doi.as_str(), "10.1234/a{c}b".replace("{c}", &c.to_string()));
1931 // The recorded identifier is itself printable. Encoding makes the
1932 // *link* safe, but a bare DOI gets printed too, and a citation
1933 // carrying a character nobody can see is not one anybody can check.
1934 // The recorded form is the identifier, so it may hold any letter
1935 // or digit — what must hold is that it carries nothing invisible.
1936 // Spelled out rather than asked of the production predicate: a
1937 // property checked through the same function that enforces it
1938 // cancels on both sides, and would pass however that function broke.
1939 assert!(
1940 doi.as_str()
1941 .chars()
1942 .all(|c| c.is_ascii_graphic() || c.is_alphanumeric()),
1943 "U+{:04X} was accepted into a DOI but cannot be printed",
1944 c as u32
1945 );
1946 let url = doi.url();
1947 let rest = url
1948 .strip_prefix("https://doi.org/")
1949 .expect("the resolver prefix");
1950 // Every character of the rendered link is one a reader can see and a
1951 // URL carries unambiguously — no fragment, no query, no bare escape.
1952 assert!(
1953 rest.chars().all(|c| c.is_ascii_graphic()),
1954 "U+{:04X} left something unprintable in {url:?}",
1955 c as u32
1956 );
1957 for reserved in ['#', '?'] {
1958 assert!(
1959 !rest.contains(reserved),
1960 "U+{:04X} left a bare {reserved:?} in {url:?}, which the resolver never receives",
1961 c as u32
1962 );
1963 }
1964 // And the encoding is reversible, so the link names the DOI that was
1965 // recorded rather than one that merely looks like it.
1966 assert_eq!(
1967 decode_escapes_independently(rest),
1968 doi.as_str(),
1969 "U+{:04X}: the URL must decode back to the recorded DOI",
1970 c as u32
1971 );
1972 // The same property through the public API, which is what a caller
1973 // actually round-trips: rendering a DOI and parsing the result must
1974 // give the DOI back. This is what makes `new` and `url` inverses
1975 // rather than two functions that happen to agree on easy input.
1976 assert_eq!(
1977 Doi::new(&url).expect("a URL this type produced"),
1978 doi,
1979 "U+{:04X}: {url:?} did not parse back to the DOI it renders",
1980 c as u32
1981 );
1982 }
1983 assert!(
1984 accepted > 50,
1985 "the sweep accepted only {accepted} code points — too narrow to be testing an allowlist"
1986 );
1987 }
1988
1989 /// Reverse [`Doi::url`]'s escaping, so a test can prove the encoding is
1990 /// lossless rather than merely well-formed.
1991 ///
1992 /// Deliberately a **second implementation**, not the production
1993 /// `percent_decode`. A round trip checked with the decoder the encoder
1994 /// ships with passes whenever the two share a defect — they cancel, and the
1995 /// test proves only that the module agrees with itself. This one is written
1996 /// independently, so the byte-level property is checked against something
1997 /// other than the code under test. The API-level round trip below
1998 /// (`Doi::new(&url) == doi`) uses the production pair on purpose, because
1999 /// *that* pairing is the guarantee a caller relies on.
2000 fn decode_escapes_independently(text: &str) -> String {
2001 let mut bytes = Vec::new();
2002 let mut rest = text.as_bytes();
2003 while let Some((first, tail)) = rest.split_first() {
2004 if *first == b'%' && tail.len() >= 2 {
2005 let hex = std::str::from_utf8(&tail[..2]).expect("ascii hex");
2006 bytes.push(u8::from_str_radix(hex, 16).expect("valid escape"));
2007 rest = &tail[2..];
2008 } else {
2009 bytes.push(*first);
2010 rest = tail;
2011 }
2012 }
2013 String::from_utf8(bytes).expect("valid utf-8")
2014 }
2015
2016 #[test]
2017 fn a_doi_url_names_the_record_that_was_recorded() {
2018 // The four URI delimiters, by name. `#` is the one that matters: raw, it
2019 // is an HTTP fragment, so `https://doi.org/10.1234/a#b` asks the resolver
2020 // for `10.1234/a` — a different record, retrieved silently and with every
2021 // appearance of working.
2022 for (recorded, expected) in [
2023 ("10.1234/a#b", "https://doi.org/10.1234/a%23b"),
2024 ("10.1234/a?b", "https://doi.org/10.1234/a%3Fb"),
2025 ("10.1234/a%b", "https://doi.org/10.1234/a%25b"),
2026 ("10.1234/a\"b", "https://doi.org/10.1234/a%22b"),
2027 ] {
2028 let doi = Doi::new(recorded).expect("a printable DOI");
2029 assert_eq!(doi.as_str(), recorded, "the record keeps what was written");
2030 assert_eq!(doi.url(), expected);
2031 }
2032 // A real, registered Wiley DOI: `<`, `>`, `(`, `;` and `:` are ordinary
2033 // in the wild, which is why this encodes rather than refuses. The
2034 // sub-delims pass through; the two angle brackets are escaped.
2035 let wiley = Doi::new("10.1002/(SICI)1097-0258(19970815)16:15<1707::AID-SIM605>3.0.CO;2-Y")
2036 .expect("a real DOI");
2037 assert_eq!(
2038 wiley.url(),
2039 "https://doi.org/10.1002/(SICI)1097-0258(19970815)16:15%3C1707::AID-SIM605%3E3.0.CO;2-Y"
2040 );
2041 // The ordinary shapes are untouched, so this costs nothing in the common
2042 // case.
2043 assert_eq!(
2044 Doi::new("10.3886/ICPSR36966.v1").expect("valid").url(),
2045 "https://doi.org/10.3886/ICPSR36966.v1"
2046 );
2047 }
2048
2049 #[test]
2050 fn what_is_stored_is_the_identifier_and_never_its_url_form() {
2051 // The two halves of this type have to agree on what the recorded value
2052 // *is*, and for one round they did not: the suffix rule admitted `%`
2053 // and the docs said a non-ASCII DOI should be recorded percent-encoded,
2054 // while `url` assumed the recorded form was raw and encoded the `%`
2055 // again — so `10.1234/a%23b` rendered `…a%2523b` and resolved to a
2056 // different record. The rule is now one sentence: what is stored is the
2057 // DOI name, raw.
2058
2059 // A resolver URL carries the *encoded* form, so parsing one decodes it.
2060 // Both spellings of one DOI therefore land on the same value.
2061 let from_url = Doi::new("https://doi.org/10.1234/a%23b").expect("a resolver URL");
2062 let from_bare = Doi::new("doi:10.1234/a#b").expect("the identifier");
2063 assert_eq!(from_url, from_bare, "one DOI, two ways of writing it down");
2064 assert_eq!(from_url.as_str(), "10.1234/a#b", "stored raw, not encoded");
2065 assert_eq!(from_url.url(), "https://doi.org/10.1234/a%23b");
2066
2067 // `new` and `url` are inverses, which is the property that makes the
2068 // link name the record it claims to.
2069 for recorded in [
2070 "10.1234/a#b",
2071 "10.1234/a%b",
2072 "10.1234/a?b",
2073 "10.1234/plain",
2074 "10.1234/ünïcode",
2075 ] {
2076 let doi = Doi::new(recorded).expect("a DOI");
2077 assert_eq!(doi.as_str(), recorded);
2078 assert_eq!(
2079 Doi::new(&doi.url()).expect("its own URL"),
2080 doi,
2081 "{recorded:?} did not survive a render-and-reparse"
2082 );
2083 }
2084 // A literal `%` is kept apart from an escape, which the encoded-storage
2085 // reading could not do: these are two different DOIs.
2086 assert_ne!(
2087 Doi::new("10.1234/a%23b").expect("a literal percent"),
2088 Doi::new("10.1234/a#b").expect("a literal hash")
2089 );
2090 // A malformed escape in a resolver URL is refused rather than guessed at.
2091 for broken in [
2092 "https://doi.org/10.1234/a%2",
2093 "https://doi.org/10.1234/a%zzb",
2094 "https://doi.org/10.1234/a%",
2095 ] {
2096 assert!(Doi::new(broken).is_err(), "{broken:?}");
2097 }
2098 }
2099
2100 #[test]
2101 fn a_non_ascii_doi_is_recordable_as_itself() {
2102 // The DOI Handbook lets a DOI name incorporate any printable character
2103 // from the Unicode Standard, so these are legal identifiers and refusing
2104 // them would be this type inventing a rule the standard does not have.
2105 // They are safe to admit because a DOI is never emitted as recorded — it
2106 // reaches a page only through `url`, which encodes.
2107 for recorded in ["10.1234/中文", "10.1234/ünïcode", "10.1234/абв"] {
2108 let doi = Doi::new(recorded).expect("a legal DOI name");
2109 assert_eq!(doi.as_str(), recorded, "recorded as the identifier itself");
2110 let url = doi.url();
2111 assert!(
2112 url.chars().all(|c| c.is_ascii_graphic()),
2113 "the rendered link is always ASCII: {url:?}"
2114 );
2115 assert_eq!(Doi::new(&url).expect("its own URL"), doi);
2116 }
2117 // A *letter* is admitted; an invisible character still is not, whichever
2118 // block it comes from.
2119 for hidden in ["10.1234/a\u{200b}b", "10.1234/a\u{061c}b", "10.1234/a b"] {
2120 assert!(Doi::new(hidden).is_err(), "{hidden:?}");
2121 }
2122 }
2123
2124 #[test]
2125 fn every_public_type_here_is_reachable_from_the_crate_root() {
2126 // `Year`, `GivenName` and their error types were public, constructible
2127 // and *not* re-exported, so a downstream caller could name the field
2128 // types of `PublicationDate` and `Author::Person` but could not build
2129 // one through the crate's own API. The assertion is the type check: this
2130 // names every public item of this module by its root path, so adding one
2131 // without re-exporting it stops compiling here.
2132 fn assert_reachable<T>() {}
2133 assert_reachable::<crate::Attested<String>>();
2134 assert_reachable::<crate::Month>();
2135 assert_reachable::<crate::Day>();
2136 assert_reachable::<crate::NotADay>();
2137 assert_reachable::<crate::Year>();
2138 assert_reachable::<crate::NotAYear>();
2139 assert_reachable::<crate::PublicationDate>();
2140 assert_reachable::<crate::AccessDate>();
2141 assert_reachable::<crate::Stability>();
2142 assert_reachable::<crate::GivenName>();
2143 assert_reachable::<crate::NotAGivenName>();
2144 assert_reachable::<crate::Author>();
2145 assert_reachable::<crate::Doi>();
2146 assert_reachable::<crate::NotADoi>();
2147 assert_reachable::<crate::Locator>();
2148 assert_reachable::<crate::WorkKind>();
2149 assert_reachable::<crate::Reference>();
2150 // A URL, not a DOI: this predicate is the rule for a value emitted
2151 // verbatim, and reaching for it with a DOI-shaped string is the
2152 // confusion the split exists to prevent.
2153 assert!(crate::is_printable_identifier("https://example.invalid/ok"));
2154
2155 // And the round trip that omission actually blocked: building the two
2156 // public shapes entirely through the crate root.
2157 let published = crate::PublicationDate::Year(crate::Year::new(2020).expect("a year"));
2158 let author = crate::Author::Person {
2159 surname: "Luna".to_owned(),
2160 given: vec![crate::GivenName::new("R").expect("a given name")],
2161 };
2162 assert_eq!(published.year().get(), 2020);
2163 assert_eq!(author.sort_key(), "Luna");
2164 }
2165
2166 #[test]
2167 fn a_locator_cannot_carry_a_character_that_escapes_an_attribute() {
2168 // This crate's output is structured spans destined for a web UI, so an
2169 // `href` is a value somebody interpolates into markup. ASCII-graphic was
2170 // the wrong bar: it admits the nine characters RFC 3986 excludes from a
2171 // URI outright, and `"` among them turns a locator into an attribute.
2172 for escaping in [
2173 "https://example.invalid/a\"onmouseover=\"alert(1)",
2174 "https://example.invalid/a\"",
2175 "https://example.invalid/a<script>",
2176 "https://example.invalid/a>b",
2177 "https://example.invalid/a\\b",
2178 "https://example.invalid/a{b}",
2179 "https://example.invalid/a^b",
2180 "https://example.invalid/a|b",
2181 "https://example.invalid/a`b",
2182 ] {
2183 assert!(
2184 !is_printable_identifier(escaping),
2185 "{escaping:?} can escape a quoted attribute and must not be a link target"
2186 );
2187 }
2188 // The characters a URL actually needs are all still there, including the
2189 // two the contract explicitly does *not* discharge the consumer of.
2190 for ordinary in [
2191 "https://example.invalid/a?b=c&d=e#f",
2192 "https://example.invalid/~user/a_b-c.d",
2193 "https://example.invalid/a'b",
2194 "https://example.invalid/(a)+b,c;d=e!f$g*h",
2195 "https://example.invalid/a%20b",
2196 "https://[::1]:8080/x",
2197 ] {
2198 assert!(is_printable_identifier(ordinary), "{ordinary:?}");
2199 }
2200 }
2201
2202 #[test]
2203 fn a_percent_that_introduces_nothing_is_not_a_uri() {
2204 // `%` is a legal URI character, so the character allowlist admits it —
2205 // but only as the introducer of an escape, and whether it is one is a
2206 // property of the three characters together. A lone `%` makes the string
2207 // not a URI, so a predicate promising "only characters RFC 3986 permits
2208 // in a URI" was not telling the truth about these.
2209 for malformed in [
2210 "https://example.invalid/a%ZZ",
2211 "https://example.invalid/a%",
2212 "https://example.invalid/a%2",
2213 "https://example.invalid/%",
2214 "https://example.invalid/a%g0b",
2215 ] {
2216 assert!(
2217 !is_printable_identifier(malformed),
2218 "{malformed:?} carries a `%` that introduces no escape"
2219 );
2220 }
2221 // Well-formed escapes are untouched, in either case, and so is a `%`
2222 // that this type itself emits.
2223 for fine in [
2224 "https://example.invalid/a%20b",
2225 "https://example.invalid/100%25",
2226 "https://example.invalid/a%2Fb",
2227 "https://example.invalid/a%2fb",
2228 ] {
2229 assert!(is_printable_identifier(fine), "{fine:?}");
2230 }
2231 // Every URL `Doi::url` can produce satisfies it, which is what keeps the
2232 // two halves of this module consistent.
2233 for recorded in [
2234 "10.1234/a#b",
2235 "10.1234/a%b",
2236 "10.1234/中文",
2237 "10.1234/plain",
2238 ] {
2239 let url = Doi::new(recorded).expect("a DOI").url();
2240 assert!(is_printable_identifier(&url), "{url:?}");
2241 }
2242 }
2243
2244 #[test]
2245 fn a_resolver_url_ends_at_a_query_or_a_fragment() {
2246 // In a URL `?` opens a query and `#` opens a fragment, and a fragment is
2247 // never sent to the server — so `https://doi.org/10.1234/a#b` asks the
2248 // resolver for `10.1234/a`. Storing `10.1234/a#b` recorded a DOI nobody
2249 // requested and re-emitted it as `…/a%23b`, a third record again.
2250 for (url, named) in [
2251 ("https://doi.org/10.1234/a#b", "10.1234/a"),
2252 ("https://doi.org/10.1234/a?b", "10.1234/a"),
2253 ("https://doi.org/10.1234/a#b?c", "10.1234/a"),
2254 ("https://doi.org/10.1234/a%23b", "10.1234/a#b"),
2255 ("https://doi.org/10.1234/plain", "10.1234/plain"),
2256 ] {
2257 assert_eq!(
2258 Doi::new(url).expect("a resolver URL").as_str(),
2259 named,
2260 "{url:?}"
2261 );
2262 }
2263 // The two input forms disagree on purpose: bare, `#` is a character of
2264 // the name; in a URL it is a delimiter. They are different statements.
2265 assert_ne!(
2266 Doi::new("10.1234/a#b").expect("a bare name"),
2267 Doi::new("https://doi.org/10.1234/a#b").expect("a URL")
2268 );
2269 // And the round trip holds for URL-shaped input too, which is the form
2270 // the property was not previously stated over.
2271 for recorded in ["10.1234/a#b", "10.1234/a?b", "10.1234/a%b"] {
2272 let doi = Doi::new(recorded).expect("a DOI");
2273 assert_eq!(
2274 Doi::new(&doi.url()).expect("its own URL"),
2275 doi,
2276 "{recorded:?} did not survive render-and-reparse as a URL"
2277 );
2278 }
2279 }
2280
2281 #[test]
2282 fn a_registrant_code_is_not_held_to_a_length_convention() {
2283 // Registrant codes are four digits or more in practice, but that is how
2284 // they have been assigned rather than a rule of the syntax — no digit
2285 // count is stated by the standards, and secondary sources say the prefix
2286 // "usually" takes the form `10.NNNN`. Enforcing the convention would
2287 // refuse a registered identifier on no authority, which is a worse
2288 // failure than accepting an unassigned one.
2289 for short in ["10.1/x", "10.12/x", "10.123/x"] {
2290 assert!(Doi::new(short).is_ok(), "{short:?}");
2291 }
2292 assert!(Doi::new("10.1000/182").is_ok(), "the DOI Foundation's own");
2293 // The shape rules that *are* stated still hold.
2294 for malformed in ["10./x", "10.a/x", "10.1./x", "11.1234/x", "10.1234"] {
2295 assert!(Doi::new(malformed).is_err(), "{malformed:?}");
2296 }
2297 }
2298
2299 #[test]
2300 fn a_date_that_did_not_happen_is_not_a_date() {
2301 // `Day` validates 1..=31 because it carries no calendar. `February 31`
2302 // is only impossible once the month is in hand, so the composite has to
2303 // ask — the same shape as every other invariant enforced on a part while
2304 // the whole went unchecked.
2305 let day = |d: u8| Day::new(d).expect("a day");
2306 for (y, m, d) in [
2307 (2021, Month::February, 31),
2308 (2021, Month::February, 30),
2309 (2021, Month::February, 29),
2310 (2021, Month::April, 31),
2311 (2021, Month::June, 31),
2312 (2021, Month::September, 31),
2313 (2021, Month::November, 31),
2314 ] {
2315 let date = PublicationDate::Full {
2316 year: year(y),
2317 month: m,
2318 day: day(d),
2319 };
2320 assert!(!date.names_a_day_that_exists(), "{m:?} {d}, {y}");
2321 assert!(
2322 !AccessDate {
2323 year: year(y),
2324 month: m,
2325 day: day(d)
2326 }
2327 .names_a_day_that_exists(),
2328 "a retrieval date shares the calendar: {m:?} {d}, {y}"
2329 );
2330 }
2331 // Leap years, both rules of them.
2332 for (y, exists) in [(2020, true), (2021, false), (2000, true), (1900, false)] {
2333 let date = PublicationDate::Full {
2334 year: year(y),
2335 month: Month::February,
2336 day: day(29),
2337 };
2338 assert_eq!(date.names_a_day_that_exists(), exists, "29 February {y}");
2339 }
2340 // Ordinary dates, and the precisions that name no day at all.
2341 assert!(
2342 PublicationDate::Full {
2343 year: year(2021),
2344 month: Month::January,
2345 day: day(31)
2346 }
2347 .names_a_day_that_exists()
2348 );
2349 assert!(PublicationDate::Year(year(2021)).names_a_day_that_exists());
2350 assert!(
2351 PublicationDate::YearMonth {
2352 year: year(2021),
2353 month: Month::February
2354 }
2355 .names_a_day_that_exists()
2356 );
2357 }
2358
2359 #[test]
2360 fn apa_alphabetises_letter_by_letter_ignoring_punctuation() {
2361 // APA means this literally: spaces and punctuation are skipped, so the
2362 // order is Ol, OM, ON. Keeping the apostrophe in the key put both
2363 // O'-names above `Olsen`, because `'` sorts below every letter.
2364 let named = |id: &str, surname: &str| {
2365 let mut reference = dated(id, surname, 2020);
2366 reference.authors = vec![Author::Person {
2367 surname: surname.to_owned(),
2368 given: vec![given_name("A")],
2369 }];
2370 reference
2371 };
2372 let mut refs = [
2373 named("oneil", "O'Neil"),
2374 named("olsen", "Olsen"),
2375 named("omalley", "O'Malley"),
2376 ];
2377 refs.sort_by(Reference::list_order);
2378 assert_eq!(
2379 refs.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
2380 ["olsen", "omalley", "oneil"]
2381 );
2382 // Two names differing only in punctuation are still ordered, and
2383 // deterministically, because the unfolded key breaks the tie.
2384 let mut ties = [named("with", "O'Neil"), named("without", "ONeil")];
2385 ties.sort_by(Reference::list_order);
2386 let order: Vec<&str> = ties.iter().map(|r| r.id.as_str()).collect();
2387 assert_eq!(order.len(), 2);
2388 assert_ne!(
2389 Reference::list_order(&ties[0], &ties[1]),
2390 Ordering::Equal,
2391 "the fold must not collapse two distinct names into one position"
2392 );
2393 }
2394
2395 #[test]
2396 fn a_kinds_wire_token_is_the_one_it_documents() {
2397 // These were allowed to disagree once: serde wrote `"DataSet"` while
2398 // `as_str` promised `data-set`, so a report and a serialised record
2399 // named one kind two ways.
2400 for kind in [
2401 WorkKind::Document,
2402 WorkKind::Software,
2403 WorkKind::DataSet,
2404 WorkKind::FactSheet,
2405 WorkKind::WebPage,
2406 ] {
2407 let json = serde_json::to_string(&kind).expect("serialize");
2408 assert_eq!(
2409 json,
2410 format!("\"{}\"", kind.as_str()),
2411 "the wire token must be the documented one"
2412 );
2413 }
2414 }
2415
2416 #[test]
2417 fn one_author_precedes_the_same_authors_collaborations() {
2418 // APA §9.47: a one-author entry comes before a multi-author entry
2419 // beginning with the same surname, and entries sharing a first author
2420 // are separated by the second author's surname — both before date is
2421 // even consulted.
2422 let mut solo = dated("solo", "Salas", 2020);
2423 solo.authors = vec![person("Salas")];
2424 let mut with_zhang = dated("with-zhang", "Salas", 1990);
2425 with_zhang.authors = vec![person("Salas"), person("Zhang")];
2426 let mut with_abbott = dated("with-abbott", "Salas", 1999);
2427 with_abbott.authors = vec![person("Salas"), person("Abbott")];
2428
2429 let mut refs = [with_zhang, solo, with_abbott];
2430 refs.sort_by(Reference::list_order);
2431 let order: Vec<&str> = refs.iter().map(|r| r.id.as_str()).collect();
2432 assert_eq!(
2433 order,
2434 ["solo", "with-abbott", "with-zhang"],
2435 "the solo work first despite its later year, then by second author"
2436 );
2437 }
2438
2439 #[test]
2440 fn two_works_from_one_year_are_separated_by_month_not_by_title() {
2441 let mut december = dated("december", "Salas", 2020);
2442 december.title = "A title".to_owned();
2443 december.published = Attested::Known(PublicationDate::YearMonth {
2444 year: year(2020),
2445 month: Month::December,
2446 });
2447 let mut january = dated("january", "Salas", 2020);
2448 // A title that sorts *after* December's, so the assertion fails if the
2449 // date key gives up at the year and lets the title decide.
2450 january.title = "Z title".to_owned();
2451 january.published = Attested::Known(PublicationDate::Full {
2452 year: year(2020),
2453 month: Month::January,
2454 day: Day::new(9).expect("valid"),
2455 });
2456
2457 let mut refs = [december, january];
2458 refs.sort_by(Reference::list_order);
2459 let order: Vec<&str> = refs.iter().map(|r| r.id.as_str()).collect();
2460 assert_eq!(order, ["january", "december"]);
2461 }
2462
2463 #[test]
2464 fn two_people_with_one_surname_are_ordered_by_their_given_names() {
2465 // A surname alone cannot separate two different people, and APA orders
2466 // them by initials before it looks at the date — so A. Smith's later
2467 // work still precedes T. Smith's earlier one.
2468 let mut anne = dated("anne", "Smith", 2020);
2469 anne.authors = vec![Author::Person {
2470 surname: "Smith".to_owned(),
2471 given: vec![given_name("Anne")],
2472 }];
2473 let mut tom = dated("tom", "Smith", 1990);
2474 tom.authors = vec![Author::Person {
2475 surname: "Smith".to_owned(),
2476 given: vec![given_name("Tom")],
2477 }];
2478
2479 let mut refs = [tom, anne];
2480 refs.sort_by(Reference::list_order);
2481 assert_eq!(
2482 refs.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
2483 ["anne", "tom"]
2484 );
2485 }
2486
2487 #[test]
2488 fn whitespace_nobody_can_see_does_not_decide_the_order() {
2489 // The renderer trims the author names and the title, so a leading space
2490 // changes where an entry sorts without changing a character of what is
2491 // printed — two lists that render identically in a different order.
2492 let padded = {
2493 let mut r = dated("padded", " Salas ", 2020);
2494 r.title = " A title ".to_owned();
2495 r
2496 };
2497 let tidy = dated("padded", "Salas", 2020);
2498 assert_eq!(
2499 Reference::list_order(&padded, &tidy),
2500 Reference::list_order(&tidy, &padded).reverse(),
2501 "the comparison is symmetric"
2502 );
2503 let mut zhang = dated("zhang", "Zhang", 2020);
2504 zhang.title = "A title".to_owned();
2505 let mut refs = [zhang, padded];
2506 refs.sort_by(Reference::list_order);
2507 assert_eq!(
2508 refs.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
2509 ["padded", "zhang"],
2510 "a padded `Salas` still sorts before `Zhang`"
2511 );
2512 }
2513
2514 #[test]
2515 fn records_alike_in_every_key_still_have_a_defined_order() {
2516 // Nothing enforces that `id` is unique, so the last resort has to be
2517 // the record itself — otherwise these two compare `Equal`, and a stable
2518 // sort then orders them by however they happened to arrive.
2519 let mut one = dated("same-id", "Salas", 2020);
2520 one.publisher = Attested::Known("A Publisher".to_owned());
2521 let mut two = dated("same-id", "Salas", 2020);
2522 two.publisher = Attested::Known("B Publisher".to_owned());
2523
2524 assert_eq!(Reference::list_order(&one, &two), Ordering::Less);
2525 let mut forwards = [one.clone(), two.clone()];
2526 forwards.sort_by(Reference::list_order);
2527 let mut backwards = [two, one];
2528 backwards.sort_by(Reference::list_order);
2529 assert_eq!(
2530 forwards, backwards,
2531 "the same pair in either order must sort the same way"
2532 );
2533 }
2534
2535 #[test]
2536 fn ordering_is_stable_whatever_order_the_input_arrives_in() {
2537 let build = || {
2538 vec![
2539 dated("a", "Zhang", 1999),
2540 dated("b", "Salas", 2020),
2541 dated("d", "Abbott", 2020),
2542 ]
2543 };
2544 let mut forwards = build();
2545 forwards.sort_by(Reference::list_order);
2546 let mut backwards = build();
2547 backwards.reverse();
2548 backwards.sort_by(Reference::list_order);
2549 assert_eq!(forwards, backwards);
2550 }
2551}