Skip to main content

aspero_core/
work.rs

1//! The minimal, provider-neutral description of a scholarly work.
2//!
3//! Shaped after CSL-JSON so export and citation formatting stay cheap, but it
4//! is not CSL-JSON itself: identifiers live on [`SourceRecord`](crate::SourceRecord),
5//! and anything source-specific stays in the provider's raw response.
6//!
7//! Every field is optional, because no provider fills everything. The structs
8//! are `#[non_exhaustive]`: build them from `Default` and assign fields, so
9//! adding a field later is not a breaking change.
10
11use std::fmt;
12use std::str::FromStr;
13
14use serde::{Deserialize, Serialize};
15
16use crate::id::is_digits;
17
18#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
19#[non_exhaustive]
20pub struct Work {
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub title: Option<String>,
23    /// In byline order. Empty means "not provided", not "anonymous".
24    #[serde(default, skip_serializing_if = "Vec::is_empty")]
25    pub authors: Vec<Person>,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub issued: Option<PartialDate>,
28    #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
29    pub work_type: Option<WorkType>,
30    /// Journal, proceedings or book title.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub container_title: Option<String>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub volume: Option<String>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub issue: Option<String>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub page: Option<String>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub publisher: Option<String>,
41    #[serde(default, rename = "abstract", skip_serializing_if = "Option::is_none")]
42    pub abstract_text: Option<String>,
43    /// Landing page for the work.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub url: Option<String>,
46    /// As counted by the providing source; counts differ between sources.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub cited_by_count: Option<u64>,
49    /// Retractions, corrections and other editorial notices about the work.
50    #[serde(default, skip_serializing_if = "Vec::is_empty")]
51    pub updates: Vec<Update>,
52    /// When the work is itself a notice, the works it updates: what an
53    /// erratum corrects or a retraction notice retracts.
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub update_to: Vec<UpdateTo>,
56    /// Whether, and where, the work can be read free and legally.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub open_access: Option<OpenAccess>,
59    /// Years of other editions or versions of the same work, when the
60    /// provider knows them (a book's later editions). A citation of any of
61    /// them is a citation of this work.
62    #[serde(default, skip_serializing_if = "Vec::is_empty")]
63    pub other_years: Vec<i16>,
64}
65
66/// An editorial notice about a work: retraction, correction, expression of
67/// concern, withdrawal.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[non_exhaustive]
70pub struct Update {
71    pub kind: UpdateKind,
72    /// The notice's own identifier (usually a DOI), if known.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub notice: Option<crate::id::WorkId>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub date: Option<PartialDate>,
77    /// Who reported it: "retraction-watch", "publisher", "openalex", ….
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub source: Option<String>,
80    /// The date the source gives for the update (Crossref's `updated`,
81    /// often the issue's), when the notice appeared earlier: `date` is then
82    /// the earliest known.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub deposited: Option<PartialDate>,
85}
86
87impl Update {
88    pub fn new(kind: UpdateKind) -> Self {
89        Update {
90            kind,
91            notice: None,
92            date: None,
93            source: None,
94            deposited: None,
95        }
96    }
97}
98
99/// Whether, and where, a work can be read free and legally (as with
100/// Unpaywall, whose data OpenAlex carries).
101#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
102#[non_exhaustive]
103pub struct OpenAccess {
104    /// Free to read somewhere.
105    pub is_oa: bool,
106    /// Unpaywall's classes: `gold`, `diamond`, `hybrid`, `bronze` (free on
107    /// the publisher's site without a licence), `green` (a repository
108    /// copy), `closed`.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub status: Option<String>,
111    /// The best free copy as a PDF.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub pdf_url: Option<String>,
114    /// The best free copy's page (HTML full text or a landing page).
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub url: Option<String>,
117    /// That copy's licence, e.g. `cc-by`; none for bronze.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub license: Option<String>,
120    /// `publishedVersion`, `acceptedVersion` or `submittedVersion`.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub version: Option<String>,
123    /// Who said so: "openalex", "europepmc", ….
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub source: Option<String>,
126}
127
128/// A notice's link to the work it updates (Crossref's `update-to`): the
129/// other direction of an [`Update`].
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[non_exhaustive]
132pub struct UpdateTo {
133    pub kind: UpdateKind,
134    /// The updated work.
135    pub work: crate::id::WorkId,
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub date: Option<PartialDate>,
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub source: Option<String>,
140}
141
142impl UpdateTo {
143    pub fn new(kind: UpdateKind, work: crate::id::WorkId) -> Self {
144        UpdateTo {
145            kind,
146            work,
147            date: None,
148            source: None,
149        }
150    }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Hash)]
154#[non_exhaustive]
155pub enum UpdateKind {
156    Retraction,
157    Withdrawal,
158    ExpressionOfConcern,
159    Correction,
160    /// A newer version or edition supersedes the work (Cochrane's
161    /// `.pub7` after `.pub6`), or a preprint's published version; the
162    /// update's `notice` is the newer work.
163    NewVersion,
164    Other(String),
165}
166
167impl UpdateKind {
168    pub fn as_str(&self) -> &str {
169        match self {
170            UpdateKind::Retraction => "retraction",
171            UpdateKind::Withdrawal => "withdrawal",
172            UpdateKind::ExpressionOfConcern => "expression_of_concern",
173            UpdateKind::Correction => "correction",
174            UpdateKind::NewVersion => "new_version",
175            UpdateKind::Other(s) => s,
176        }
177    }
178
179    /// From Crossref/Retraction Watch type names (`retraction`,
180    /// `expression_of_concern`, `erratum`, …); never fails.
181    pub fn parse(s: &str) -> Self {
182        match s.to_ascii_lowercase().replace('-', "_").as_str() {
183            "retraction" | "partial_retraction" => UpdateKind::Retraction,
184            "withdrawal" | "removal" => UpdateKind::Withdrawal,
185            "expression_of_concern" => UpdateKind::ExpressionOfConcern,
186            "correction" | "erratum" | "corrigendum" | "addendum" => UpdateKind::Correction,
187            "new_version" | "new_edition" => UpdateKind::NewVersion,
188            _ => UpdateKind::Other(s.to_owned()),
189        }
190    }
191
192    /// Whether citing the work is unsafe: retracted or withdrawn.
193    pub fn is_retraction(&self) -> bool {
194        matches!(self, UpdateKind::Retraction | UpdateKind::Withdrawal)
195    }
196}
197
198impl Serialize for UpdateKind {
199    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
200        s.serialize_str(self.as_str())
201    }
202}
203
204impl<'de> Deserialize<'de> for UpdateKind {
205    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
206        Ok(UpdateKind::parse(&String::deserialize(d)?))
207    }
208}
209
210impl Work {
211    /// The first retraction or withdrawal notice, if any.
212    pub fn retraction(&self) -> Option<&Update> {
213        self.updates.iter().find(|u| u.kind.is_retraction())
214    }
215
216    /// Types a work that is itself a notice (it has `update_to`) as one:
217    /// `retraction` for retractions and withdrawals, `erratum` for other
218    /// notices, as OpenAlex names them. Registries type notices as plain
219    /// articles. New versions are works in their own right and keep theirs.
220    pub fn type_as_notice(&mut self) {
221        let kinds = || self.update_to.iter().map(|t| &t.kind);
222        let name = if kinds().any(UpdateKind::is_retraction) {
223            "retraction"
224        } else if kinds().any(|k| *k != UpdateKind::NewVersion) {
225            "erratum"
226        } else {
227            return;
228        };
229        self.work_type = Some(WorkType::Other(name.into()));
230    }
231
232    /// The newer version superseding this work, if one is known.
233    pub fn newer_version(&self) -> Option<&crate::id::WorkId> {
234        self.updates
235            .iter()
236            .filter(|u| u.kind == UpdateKind::NewVersion)
237            .find_map(|u| u.notice.as_ref())
238    }
239}
240
241#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
242#[non_exhaustive]
243pub struct Person {
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub given: Option<String>,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub family: Option<String>,
248    /// A name that was not split into given/family, or an organisation.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub literal: Option<String>,
251    /// Bare ORCID iD, e.g. `0000-0002-1825-0097`.
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub orcid: Option<String>,
254}
255
256impl Person {
257    pub fn new(given: impl Into<String>, family: impl Into<String>) -> Self {
258        Person {
259            given: Some(given.into()),
260            family: Some(family.into()),
261            ..Person::default()
262        }
263    }
264
265    pub fn literal(name: impl Into<String>) -> Self {
266        Person {
267            literal: Some(name.into()),
268            ..Person::default()
269        }
270    }
271
272    /// Family and given names: as given, or split from a name written
273    /// whole ("Vincent van Gogh" → "van Gogh", "Vincent"). `None` for an
274    /// organisation, or a name that may be one ("WHO", "GBD 2019
275    /// Collaborators").
276    pub fn parts(&self) -> Option<(String, Option<String>)> {
277        match (&self.family, &self.literal) {
278            (Some(family), _) => Some((family.clone(), self.given.clone())),
279            (None, Some(literal)) => split_literal(literal),
280            (None, None) => None,
281        }
282    }
283
284    /// The given names' initials: "Dong Vu" → "DV", "Jean-Pierre" → "JP",
285    /// "T. D. N." → "TDN".
286    pub fn initials(&self) -> Option<String> {
287        let (_, given) = self.parts()?;
288        let initials: String = given?
289            .split(|c: char| c.is_whitespace() || c == '-' || c == '.')
290            .filter_map(|part| part.chars().find(|c| c.is_alphabetic()))
291            .flat_map(char::to_uppercase)
292            .collect();
293        (!initials.is_empty()).then_some(initials)
294    }
295
296    /// The name as Vancouver writes it: "Laan DV"; an organisation as
297    /// written.
298    pub fn short(&self) -> Option<String> {
299        match self.parts() {
300            Some((family, _)) => Some(match self.initials() {
301                Some(initials) => format!("{family} {initials}"),
302                None => family,
303            }),
304            None => self.display_name(),
305        }
306    }
307
308    /// The best single-string name: "Given Family", or the literal.
309    pub fn display_name(&self) -> Option<String> {
310        match (&self.given, &self.family, &self.literal) {
311            (Some(g), Some(f), _) => Some(format!("{g} {f}")),
312            (None, Some(f), _) => Some(f.clone()),
313            (_, _, Some(l)) => Some(l.clone()),
314            (Some(g), None, None) => Some(g.clone()),
315            (None, None, None) => None,
316        }
317    }
318}
319
320/// Lowercase words that join a family name: "van Gogh", "de la Cruz".
321const PARTICLES: [&str; 14] = [
322    "van", "von", "de", "der", "den", "da", "di", "du", "la", "le", "del", "dos", "ten", "ter",
323];
324
325/// Words that make a name an organisation's.
326const BODIES: [&str; 40] = [
327    "university",
328    "institute",
329    "institution",
330    "group",
331    "team",
332    "organization",
333    "organisation",
334    "consortium",
335    "committee",
336    "association",
337    "society",
338    "collaboration",
339    "network",
340    "centre",
341    "center",
342    "foundation",
343    "agency",
344    "council",
345    "department",
346    "ministry",
347    "office",
348    "board",
349    "federation",
350    "alliance",
351    "project",
352    "initiative",
353    "commission",
354    "programme",
355    "program",
356    "inc",
357    "ltd",
358    "corporation",
359    "company",
360    "trust",
361    "investigators",
362    "study",
363    "panel",
364    "laboratory",
365    "hospital",
366    "college",
367];
368
369/// A personal name written whole ("Yann LeCun", "Vincent van Gogh"), as
370/// family and given names; `None` for anything that may be an
371/// organisation's.
372fn split_literal(literal: &str) -> Option<(String, Option<String>)> {
373    let words: Vec<&str> = literal.split_whitespace().collect();
374    let personal = (2..=5).contains(&words.len())
375        && words.iter().all(|w| {
376            let lower = w.trim_end_matches(['.', ',']).to_lowercase();
377            let capital = w.chars().next().is_some_and(char::is_uppercase);
378            let acronym = w.chars().count() > 2 && w.chars().all(|c| !c.is_lowercase());
379            (capital || PARTICLES.contains(&lower.as_str()))
380                && !acronym
381                && !BODIES.contains(&lower.as_str())
382                && !w.chars().any(|c| c.is_ascii_digit())
383        });
384    if !personal {
385        return None;
386    }
387    let mut start = words.len() - 1;
388    while start > 1 && PARTICLES.contains(&words[start - 1]) {
389        start -= 1;
390    }
391    Some((words[start..].join(" "), Some(words[..start].join(" "))))
392}
393
394/// Removes an "Abstract" heading that some sources leave at the start of
395/// abstract text (JATS `<title>Abstract</title>`, kept as a word by
396/// OpenAlex). Only a standalone heading: "Abstraction is…" is untouched.
397pub fn tidy_abstract(text: &str) -> String {
398    let text = text.trim();
399    for heading in ["Abstract", "ABSTRACT"] {
400        if let Some(rest) = text.strip_prefix(heading)
401            && rest.starts_with(|c: char| c.is_whitespace() || matches!(c, ':' | '.' | '-' | '—'))
402        {
403            return rest
404                .trim_start_matches(|c: char| {
405                    c.is_whitespace() || matches!(c, ':' | '.' | '-' | '—')
406                })
407                .to_owned();
408        }
409    }
410    text.to_owned()
411}
412
413/// Years a work could have been published in: a four-digit number in
414/// text is read as a year only within these.
415pub(crate) const PLAUSIBLE_YEARS: std::ops::RangeInclusive<i16> = 1600..=2100;
416
417/// A date known to year, month or day precision. Serialises as `2017`,
418/// `2017-06` or `2017-06-12`.
419#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
420pub struct PartialDate {
421    year: i16,
422    month: Option<u8>,
423    day: Option<u8>,
424}
425
426#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
427#[error("invalid date {0:?}: expected YYYY, YYYY-MM or YYYY-MM-DD")]
428pub struct DateError(String);
429
430impl PartialDate {
431    pub fn from_year(year: i16) -> Self {
432        PartialDate {
433            year,
434            month: None,
435            day: None,
436        }
437    }
438
439    /// `None` if the month or day is out of range for the calendar.
440    pub fn new(year: i16, month: Option<u8>, day: Option<u8>) -> Option<Self> {
441        match (month, day) {
442            (None, Some(_)) => return None,
443            (Some(m), None) if !(1..=12).contains(&m) => return None,
444            (Some(m), Some(d)) => {
445                jiff::civil::Date::new(year, i8::try_from(m).ok()?, i8::try_from(d).ok()?).ok()?;
446            }
447            _ => {}
448        }
449        Some(PartialDate { year, month, day })
450    }
451
452    pub fn year(&self) -> i16 {
453        self.year
454    }
455
456    pub fn month(&self) -> Option<u8> {
457        self.month
458    }
459
460    pub fn day(&self) -> Option<u8> {
461        self.day
462    }
463}
464
465impl FromStr for PartialDate {
466    type Err = DateError;
467
468    fn from_str(s: &str) -> Result<Self, DateError> {
469        let err = || DateError(s.to_owned());
470        let mut parts = s.trim().splitn(3, '-');
471        // The next part, if it is `len` digits.
472        let mut num = |len: usize| match parts.next() {
473            None => Ok(None),
474            Some(p) if p.len() == len && is_digits(p) => Ok(Some(p)),
475            Some(_) => Err(err()),
476        };
477        let year = num(4)?.ok_or_else(err)?;
478        let month = num(2)?;
479        let day = num(2)?;
480        // Four digits fit an `i16`, two a `u8`.
481        let two = |d: &str| d.parse().expect("two digits");
482        PartialDate::new(
483            year.parse().expect("four digits"),
484            month.map(two),
485            day.map(two),
486        )
487        .ok_or_else(err)
488    }
489}
490
491impl fmt::Display for PartialDate {
492    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
493        write!(f, "{:04}", self.year)?;
494        if let Some(m) = self.month {
495            write!(f, "-{m:02}")?;
496        }
497        if let Some(d) = self.day {
498            write!(f, "-{d:02}")?;
499        }
500        Ok(())
501    }
502}
503
504/// Kind of work, as CSL types. Unknown upstream types are kept verbatim in
505/// `Other` rather than dropped.
506#[derive(Debug, Clone, PartialEq, Eq, Hash)]
507#[non_exhaustive]
508pub enum WorkType {
509    JournalArticle,
510    ConferencePaper,
511    /// CSL `article`: preprints and other unreviewed articles.
512    Preprint,
513    Book,
514    Chapter,
515    Thesis,
516    Report,
517    Dataset,
518    Software,
519    Review,
520    Other(String),
521}
522
523impl WorkType {
524    /// The CSL type name.
525    pub fn as_csl(&self) -> &str {
526        match self {
527            WorkType::JournalArticle => "article-journal",
528            WorkType::ConferencePaper => "paper-conference",
529            WorkType::Preprint => "article",
530            WorkType::Book => "book",
531            WorkType::Chapter => "chapter",
532            WorkType::Thesis => "thesis",
533            WorkType::Report => "report",
534            WorkType::Dataset => "dataset",
535            WorkType::Software => "software",
536            WorkType::Review => "review",
537            WorkType::Other(s) => s,
538        }
539    }
540
541    /// Inverse of [`WorkType::as_csl`]; never fails.
542    pub fn from_csl(s: &str) -> Self {
543        match s {
544            "article-journal" => WorkType::JournalArticle,
545            "paper-conference" => WorkType::ConferencePaper,
546            "article" => WorkType::Preprint,
547            "book" => WorkType::Book,
548            "chapter" => WorkType::Chapter,
549            "thesis" => WorkType::Thesis,
550            "report" => WorkType::Report,
551            "dataset" => WorkType::Dataset,
552            "software" => WorkType::Software,
553            "review" => WorkType::Review,
554            other => WorkType::Other(other.to_owned()),
555        }
556    }
557}
558
559impl Serialize for WorkType {
560    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
561        s.serialize_str(self.as_csl())
562    }
563}
564
565impl<'de> Deserialize<'de> for WorkType {
566    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
567        Ok(WorkType::from_csl(&String::deserialize(d)?))
568    }
569}
570
571impl Serialize for PartialDate {
572    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
573        s.collect_str(self)
574    }
575}
576
577impl<'de> Deserialize<'de> for PartialDate {
578    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
579        String::deserialize(d)?
580            .parse()
581            .map_err(serde::de::Error::custom)
582    }
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    #[test]
590    fn names_split_and_shorten_as_vancouver_writes_them() {
591        let short = |p: Person| p.short().unwrap();
592        assert_eq!(short(Person::new("Dong V.", "Laan")), "Laan DV");
593        assert_eq!(short(Person::new("Thi Diem Ngoc", "Vu")), "Vu TDN");
594        assert_eq!(short(Person::new("Jean-Pierre", "Dupont")), "Dupont JP");
595        assert_eq!(short(Person::literal("Yann LeCun")), "LeCun Y");
596        assert_eq!(short(Person::literal("Vincent van Gogh")), "van Gogh V");
597        assert_eq!(short(Person::literal("WHO")), "WHO");
598        assert_eq!(
599            short(Person::literal("GBD 2019 Collaborators")),
600            "GBD 2019 Collaborators"
601        );
602        assert_eq!(
603            Person::literal("Vincent van Gogh").parts(),
604            Some(("van Gogh".into(), Some("Vincent".into())))
605        );
606        assert_eq!(Person::literal("World Health Organization").parts(), None);
607    }
608
609    #[test]
610    fn partial_dates_parse_validate_and_round_trip() {
611        for s in ["2017", "2017-06", "2017-06-12", "2024-02-29"] {
612            assert_eq!(s.parse::<PartialDate>().unwrap().to_string(), s);
613        }
614        for s in [
615            "17",
616            "2017-6",
617            "2017-13",
618            "2017-06-31",
619            "2023-02-29",
620            "2017--01",
621            "x",
622        ] {
623            assert!(s.parse::<PartialDate>().is_err(), "{s:?}");
624        }
625        assert_eq!(PartialDate::new(2017, None, Some(3)), None);
626    }
627
628    #[test]
629    fn work_type_keeps_unknown_values() {
630        assert_eq!(
631            WorkType::from_csl("article-journal"),
632            WorkType::JournalArticle
633        );
634        let other = WorkType::from_csl("musical-score");
635        assert_eq!(other.as_csl(), "musical-score");
636    }
637
638    #[test]
639    fn work_serialises_sparsely_with_csl_names() {
640        let w = Work {
641            title: Some("Attention Is All You Need".into()),
642            issued: Some(PartialDate::from_year(2017)),
643            work_type: Some(WorkType::ConferencePaper),
644            authors: vec![Person::new("Ashish", "Vaswani")],
645            ..Work::default()
646        };
647        let json = serde_json::to_value(&w).unwrap();
648        assert_eq!(
649            json,
650            serde_json::json!({
651                "title": "Attention Is All You Need",
652                "authors": [{"given": "Ashish", "family": "Vaswani"}],
653                "issued": "2017",
654                "type": "paper-conference",
655            })
656        );
657        assert_eq!(serde_json::from_value::<Work>(json).unwrap(), w);
658    }
659
660    #[test]
661    fn tidy_abstract_drops_only_a_standalone_heading() {
662        assert_eq!(
663            tidy_abstract("Abstract Proteins are essential"),
664            "Proteins are essential"
665        );
666        assert_eq!(tidy_abstract("ABSTRACT: We show"), "We show");
667        assert_eq!(
668            tidy_abstract("  Abstraction is useful"),
669            "Abstraction is useful"
670        );
671        assert_eq!(tidy_abstract("We show"), "We show");
672    }
673
674    #[test]
675    fn person_display_name_prefers_split_names() {
676        assert_eq!(
677            Person::new("Ada", "Lovelace").display_name().unwrap(),
678            "Ada Lovelace"
679        );
680        assert_eq!(Person::literal("CERN").display_name().unwrap(), "CERN");
681        assert_eq!(Person::default().display_name(), None);
682    }
683}