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