Skip to main content

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}