Skip to main content

citum_schema_data/reference/
accessors.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Typed read/mutation surface for [`InputReference`].
7//!
8//! All shared bibliographic data lives inside the class-specific payload in
9//! `extension`; this file dispatches accessors through every known class.
10
11use serde_json::Value as JsonValue;
12use url::Url;
13
14use super::classes::class_dispatch;
15use super::contributor::{Contributor, ContributorEntry, ContributorList, ContributorRole};
16use super::date::DateValue;
17use super::types::common::{
18    FieldLanguageMap, HasNumbering, LangID, MultilingualString, NumOrStr, NumberingType, Publisher,
19    RefID, RichText, Title,
20};
21use super::types::legal::{Brief, Hearing, LegalCase, Regulation, Statute, Treaty};
22use super::types::specialized::{
23    AudioVisualType, AudioVisualWork, Classic, Dataset, Event, Patent, Software, Standard,
24};
25use super::types::structural::{
26    Collection, CollectionComponent, CollectionType, Monograph, MonographComponentType,
27    MonographType, Serial, SerialComponent, SerialType,
28};
29use super::{
30    ClassExtension, EMPTY_FIELD_LANGUAGES, IdentifierName, InputReference, ReferenceClass,
31    SupplementaryIdentifiers, UnknownClassData, WorkRelation,
32};
33
34impl InputReference {
35    /// Return all supplementary standardized identifiers.
36    #[must_use]
37    pub fn identifiers(&self) -> &SupplementaryIdentifiers {
38        &self.identifiers
39    }
40
41    /// Return one supplementary standardized identifier.
42    #[must_use]
43    pub fn identifier(&self, name: &str) -> Option<&str> {
44        self.identifiers.get(name)
45    }
46
47    /// Insert one supplementary standardized identifier.
48    pub fn insert_identifier(
49        &mut self,
50        name: IdentifierName,
51        value: impl Into<String>,
52    ) -> Option<String> {
53        self.identifiers.insert(name, value)
54    }
55
56    /// Return the typed class discriminator.
57    #[must_use]
58    pub fn class(&self) -> ReferenceClass {
59        self.extension.reference_class()
60    }
61
62    /// Return the active class-specific overlay.
63    #[must_use]
64    pub fn extension(&self) -> &ClassExtension {
65        &self.extension
66    }
67
68    /// Return the mutable active class-specific overlay.
69    #[must_use]
70    pub fn extension_mut(&mut self) -> &mut ClassExtension {
71        &mut self.extension
72    }
73
74    /// Return monograph data when this reference is a monograph.
75    #[must_use]
76    pub fn as_monograph(&self) -> Option<&Monograph> {
77        match &self.extension {
78            ClassExtension::Monograph(reference) => Some(reference.as_ref()),
79            _ => None,
80        }
81    }
82
83    /// Return collection-component data when this reference is a collection component.
84    #[must_use]
85    pub fn as_collection_component(&self) -> Option<&CollectionComponent> {
86        match &self.extension {
87            ClassExtension::CollectionComponent(reference) => Some(reference.as_ref()),
88            _ => None,
89        }
90    }
91
92    /// Return serial-component data when this reference is a serial component.
93    #[must_use]
94    pub fn as_serial_component(&self) -> Option<&SerialComponent> {
95        match &self.extension {
96            ClassExtension::SerialComponent(reference) => Some(reference.as_ref()),
97            _ => None,
98        }
99    }
100
101    /// Return collection data when this reference is a collection.
102    #[must_use]
103    pub fn as_collection(&self) -> Option<&Collection> {
104        match &self.extension {
105            ClassExtension::Collection(reference) => Some(reference.as_ref()),
106            _ => None,
107        }
108    }
109
110    /// Return serial data when this reference is a serial.
111    #[must_use]
112    pub fn as_serial(&self) -> Option<&Serial> {
113        match &self.extension {
114            ClassExtension::Serial(reference) => Some(reference.as_ref()),
115            _ => None,
116        }
117    }
118
119    /// Return legal-case data when this reference is a legal case.
120    #[must_use]
121    pub fn as_legal_case(&self) -> Option<&LegalCase> {
122        match &self.extension {
123            ClassExtension::LegalCase(reference) => Some(reference.as_ref()),
124            _ => None,
125        }
126    }
127
128    /// Return statute data when this reference is a statute.
129    #[must_use]
130    pub fn as_statute(&self) -> Option<&Statute> {
131        match &self.extension {
132            ClassExtension::Statute(reference) => Some(reference.as_ref()),
133            _ => None,
134        }
135    }
136
137    /// Return treaty data when this reference is a treaty.
138    #[must_use]
139    pub fn as_treaty(&self) -> Option<&Treaty> {
140        match &self.extension {
141            ClassExtension::Treaty(reference) => Some(reference.as_ref()),
142            _ => None,
143        }
144    }
145
146    /// Return hearing data when this reference is a hearing.
147    #[must_use]
148    pub fn as_hearing(&self) -> Option<&Hearing> {
149        match &self.extension {
150            ClassExtension::Hearing(reference) => Some(reference.as_ref()),
151            _ => None,
152        }
153    }
154
155    /// Return regulation data when this reference is a regulation.
156    #[must_use]
157    pub fn as_regulation(&self) -> Option<&Regulation> {
158        match &self.extension {
159            ClassExtension::Regulation(reference) => Some(reference.as_ref()),
160            _ => None,
161        }
162    }
163
164    /// Return brief data when this reference is a brief.
165    #[must_use]
166    pub fn as_brief(&self) -> Option<&Brief> {
167        match &self.extension {
168            ClassExtension::Brief(reference) => Some(reference.as_ref()),
169            _ => None,
170        }
171    }
172
173    /// Return classic-work data when this reference is a classic.
174    #[must_use]
175    pub fn as_classic(&self) -> Option<&Classic> {
176        match &self.extension {
177            ClassExtension::Classic(reference) => Some(reference.as_ref()),
178            _ => None,
179        }
180    }
181
182    /// Return patent data when this reference is a patent.
183    #[must_use]
184    pub fn as_patent(&self) -> Option<&Patent> {
185        match &self.extension {
186            ClassExtension::Patent(reference) => Some(reference.as_ref()),
187            _ => None,
188        }
189    }
190
191    /// Return dataset data when this reference is a dataset.
192    #[must_use]
193    pub fn as_dataset(&self) -> Option<&Dataset> {
194        match &self.extension {
195            ClassExtension::Dataset(reference) => Some(reference.as_ref()),
196            _ => None,
197        }
198    }
199
200    /// Return standard data when this reference is a standard.
201    #[must_use]
202    pub fn as_standard(&self) -> Option<&Standard> {
203        match &self.extension {
204            ClassExtension::Standard(reference) => Some(reference.as_ref()),
205            _ => None,
206        }
207    }
208
209    /// Return software data when this reference is software.
210    #[must_use]
211    pub fn as_software(&self) -> Option<&Software> {
212        match &self.extension {
213            ClassExtension::Software(reference) => Some(reference.as_ref()),
214            _ => None,
215        }
216    }
217
218    /// Return event data when this reference is an event.
219    #[must_use]
220    pub fn as_event(&self) -> Option<&Event> {
221        match &self.extension {
222            ClassExtension::Event(reference) => Some(reference.as_ref()),
223            _ => None,
224        }
225    }
226
227    /// Return audio-visual data when this reference is audio-visual.
228    #[must_use]
229    pub fn as_audio_visual(&self) -> Option<&AudioVisualWork> {
230        match &self.extension {
231            ClassExtension::AudioVisual(reference) => Some(reference.as_ref()),
232            _ => None,
233        }
234    }
235
236    /// Return unknown-class data when this reference names an unknown class.
237    #[must_use]
238    pub fn unknown_class(&self) -> Option<&UnknownClassData> {
239        match &self.extension {
240            ClassExtension::Unknown(data) => Some(data),
241            _ => None,
242        }
243    }
244
245    fn numbered(&self) -> Option<&dyn HasNumbering> {
246        match &self.extension {
247            ClassExtension::Monograph(reference) => Some(reference.as_ref()),
248            ClassExtension::Collection(reference) => Some(reference.as_ref()),
249            ClassExtension::CollectionComponent(reference) => Some(reference.as_ref()),
250            ClassExtension::SerialComponent(reference) => Some(reference.as_ref()),
251            ClassExtension::Classic(reference) => Some(reference.as_ref()),
252            ClassExtension::AudioVisual(reference) => Some(reference.as_ref()),
253            _ => None,
254        }
255    }
256
257    /// Internal helper to find a numbering by type.
258    fn find_numbering(&self, numbering_type: NumberingType) -> Option<String> {
259        self.numbered()
260            .and_then(|reference| reference.find_numbering(numbering_type))
261    }
262
263    /// Return the numbering value for an arbitrary numbering kind.
264    pub fn numbering_value(&self, numbering_type: &NumberingType) -> Option<String> {
265        self.find_numbering(numbering_type.clone())
266    }
267
268    /// Return the reference ID.
269    pub fn id(&self) -> Option<RefID> {
270        class_dispatch!(&self.extension, |r| r.id.clone(), unknown(data) => data
271                .fields
272                .get("id")
273                .and_then(JsonValue::as_str)
274                .map(|id| RefID(id.to_string())))
275    }
276
277    /// Return the author.
278    pub fn author(&self) -> Option<Contributor> {
279        use crate::reference::contributor::ContributorRole as DataRole;
280
281        match &self.extension {
282            ClassExtension::Monograph(r) => {
283                collect_contributors_by_role(&r.contributors, &DataRole::Author)
284                    .or_else(|| r.author.clone())
285            }
286            ClassExtension::CollectionComponent(r) => {
287                collect_contributors_by_role(&r.contributors, &DataRole::Author)
288                    .or_else(|| r.author.clone())
289            }
290            ClassExtension::SerialComponent(r) => {
291                let explicit_author =
292                    collect_contributors_by_role(&r.contributors, &DataRole::Author)
293                        .or_else(|| r.author.clone());
294
295                explicit_author.or_else(|| {
296                    let av_like = r
297                        .medium
298                        .as_deref()
299                        .map(|value| {
300                            let lowered = value.to_ascii_lowercase();
301                            lowered.contains("podcast")
302                                || lowered.contains("tv")
303                                || lowered.contains("film")
304                                || lowered.contains("video")
305                        })
306                        .unwrap_or(false)
307                        || r.genre
308                            .as_deref()
309                            .map(|value| {
310                                let lowered = value.to_ascii_lowercase();
311                                lowered.contains("broadcast") || lowered.contains("film")
312                            })
313                            .unwrap_or(false);
314
315                    if av_like {
316                        collect_contributors_by_role(&r.contributors, &DataRole::Producer).or_else(
317                            || collect_contributors_by_role(&r.contributors, &DataRole::Host),
318                        )
319                    } else {
320                        None
321                    }
322                })
323            }
324            ClassExtension::Treaty(r) => r.author.clone(),
325            ClassExtension::Brief(r) => r.author.clone(),
326            ClassExtension::Classic(r) => r.author.clone(),
327            ClassExtension::Patent(r) => r.author.clone(),
328            ClassExtension::Dataset(r) => r.author.clone(),
329            ClassExtension::Software(r) => r.author.clone(),
330            ClassExtension::Event(r) => {
331                collect_contributors_by_role(&r.contributors, &DataRole::Performer)
332                    .or_else(|| {
333                        collect_contributors_by_role(
334                            &r.contributors,
335                            &DataRole::Unknown("organizer".to_string()),
336                        )
337                    })
338                    .or_else(|| collect_contributors_by_role(&r.contributors, &DataRole::Author))
339            }
340            ClassExtension::AudioVisual(r) => {
341                let explicit_author =
342                    collect_contributors_by_role(&r.core.contributors, &DataRole::Author);
343
344                match r.r#type {
345                    AudioVisualType::Film | AudioVisualType::Episode => {
346                        explicit_author.or_else(|| {
347                            collect_contributors_by_role(&r.core.contributors, &DataRole::Director)
348                        })
349                    }
350                    AudioVisualType::Recording => explicit_author.or_else(|| {
351                        collect_contributors_by_role(&r.core.contributors, &DataRole::Composer)
352                            .or_else(|| {
353                                collect_contributors_by_role(
354                                    &r.core.contributors,
355                                    &DataRole::Performer,
356                                )
357                            })
358                    }),
359                    AudioVisualType::Broadcast => explicit_author
360                        .or_else(|| {
361                            collect_contributors_by_role(&r.core.contributors, &DataRole::Director)
362                        })
363                        .or_else(|| {
364                            collect_contributors_by_role(&r.core.contributors, &DataRole::Producer)
365                        }),
366                }
367            }
368            _ => None,
369        }
370    }
371
372    pub fn editor(&self) -> Option<Contributor> {
373        match &self.extension {
374            ClassExtension::Monograph(r) => {
375                collect_contributors_by_role(&r.contributors, &ContributorRole::Editor)
376                    .or_else(|| r.editor.clone())
377            }
378            ClassExtension::Collection(r) => {
379                collect_contributors_by_role(&r.contributors, &ContributorRole::Editor)
380                    .or_else(|| r.editor.clone())
381            }
382            ClassExtension::CollectionComponent(r) => r
383                .container
384                .as_ref()
385                .and_then(|c| match c {
386                    WorkRelation::Embedded(p) => p.editor(),
387                    WorkRelation::Id(_) => None,
388                })
389                .or_else(|| {
390                    collect_contributors_by_role(&r.contributors, &ContributorRole::Editor)
391                }),
392            ClassExtension::SerialComponent(r) => r.container.as_ref().and_then(|c| match c {
393                WorkRelation::Embedded(p) => p.editor(),
394                WorkRelation::Id(_) => None,
395            }),
396            ClassExtension::Serial(r) => {
397                collect_contributors_by_role(&r.contributors, &ContributorRole::Editor)
398                    .or_else(|| r.editor.clone())
399            }
400            ClassExtension::Classic(r) => r.editor.clone(),
401            ClassExtension::AudioVisual(_) => None,
402            _ => None,
403        }
404    }
405
406    /// Return an editor attached directly to this work, excluding an enclosing container's editor.
407    pub fn local_editor(&self) -> Option<Contributor> {
408        match &self.extension {
409            ClassExtension::Monograph(r) => {
410                collect_contributors_by_role(&r.contributors, &ContributorRole::Editor)
411                    .or_else(|| r.editor.clone())
412            }
413            ClassExtension::Collection(r) => {
414                collect_contributors_by_role(&r.contributors, &ContributorRole::Editor)
415                    .or_else(|| r.editor.clone())
416            }
417            ClassExtension::CollectionComponent(r) => {
418                collect_contributors_by_role(&r.contributors, &ContributorRole::Editor)
419            }
420            ClassExtension::Serial(r) => {
421                collect_contributors_by_role(&r.contributors, &ContributorRole::Editor)
422                    .or_else(|| r.editor.clone())
423            }
424            ClassExtension::Classic(r) => r.editor.clone(),
425            _ => None,
426        }
427    }
428
429    /// Return the translator.
430    pub fn translator(&self) -> Option<Contributor> {
431        match &self.extension {
432            ClassExtension::Monograph(r) => {
433                collect_contributors_by_role(&r.contributors, &ContributorRole::Translator)
434                    .or_else(|| r.translator.clone())
435            }
436            ClassExtension::CollectionComponent(r) => {
437                collect_contributors_by_role(&r.contributors, &ContributorRole::Translator)
438                    .or_else(|| r.translator.clone())
439            }
440            ClassExtension::SerialComponent(r) => {
441                collect_contributors_by_role(&r.contributors, &ContributorRole::Translator)
442                    .or_else(|| r.translator.clone())
443            }
444            ClassExtension::Collection(r) => {
445                collect_contributors_by_role(&r.contributors, &ContributorRole::Translator)
446                    .or_else(|| r.translator.clone())
447            }
448            ClassExtension::Classic(r) => r.translator.clone(),
449            ClassExtension::AudioVisual(r) => {
450                collect_contributors_by_role(&r.core.contributors, &ContributorRole::Translator)
451            }
452            _ => None,
453        }
454    }
455
456    /// Return the publisher.
457    pub fn publisher(&self) -> Option<Publisher> {
458        match &self.extension {
459            ClassExtension::Monograph(r) => r.publisher.clone(),
460            ClassExtension::CollectionComponent(r) => r.container.as_ref().and_then(|c| match c {
461                WorkRelation::Embedded(p) => p.publisher(),
462                WorkRelation::Id(_) => None,
463            }),
464            ClassExtension::SerialComponent(r) => r.container.as_ref().and_then(|c| match c {
465                WorkRelation::Embedded(p) => p.publisher(),
466                WorkRelation::Id(_) => None,
467            }),
468            ClassExtension::Collection(r) => r.publisher.clone(),
469            ClassExtension::Serial(r) => r.publisher.clone(),
470            ClassExtension::Classic(r) => r.publisher.clone(),
471            ClassExtension::Dataset(r) => r.publisher.clone(),
472            ClassExtension::Standard(r) => r.publisher.clone(),
473            ClassExtension::Software(r) => r.publisher.clone(),
474            ClassExtension::AudioVisual(r) => r.publisher.clone(),
475            _ => None,
476        }
477    }
478
479    /// Returns contributors matching `role` for any reference class that
480    /// carries a contributors list.
481    ///
482    /// Returns `None` if no contributors with the given role exist.
483    /// Returns a single `Contributor` directly, or folds multiple into a `ContributorList`.
484    pub fn contributor(&self, role: ContributorRole) -> Option<Contributor> {
485        let entries = self.all_contributor_entries();
486        collect_contributors_by_role(entries, &role)
487    }
488
489    /// Return all contributor entries matching the requested role.
490    pub fn contributor_entries(&self, role: &ContributorRole) -> Vec<&ContributorEntry> {
491        self.all_contributor_entries()
492            .iter()
493            .filter(|entry| entry.roles.contains(role))
494            .collect()
495    }
496
497    /// Return all contributor entries regardless of role.
498    pub fn all_contributor_entries(&self) -> &[ContributorEntry] {
499        match &self.extension {
500            ClassExtension::Monograph(r) => &r.contributors,
501            ClassExtension::Collection(r) => &r.contributors,
502            ClassExtension::CollectionComponent(r) => &r.contributors,
503            ClassExtension::Serial(r) => &r.contributors,
504            ClassExtension::SerialComponent(r) => &r.contributors,
505            ClassExtension::Event(r) => &r.contributors,
506            ClassExtension::AudioVisual(r) => &r.core.contributors,
507            _ => &[],
508        }
509    }
510
511    /// Return the title.
512    pub fn title(&self) -> Option<Title> {
513        match &self.extension {
514            ClassExtension::Monograph(r) => match (&r.title, &r.short_title) {
515                (Some(Title::Single(long)), Some(short)) => {
516                    Some(Title::Shorthand(short.clone(), long.clone()))
517                }
518                _ => r.title.clone(),
519            },
520            ClassExtension::CollectionComponent(r) => r.title.clone(),
521            ClassExtension::SerialComponent(r) => r.title.clone(),
522            ClassExtension::Collection(r) => match (&r.title, &r.short_title) {
523                (Some(Title::Single(long)), Some(short)) => {
524                    Some(Title::Shorthand(short.clone(), long.clone()))
525                }
526                _ => r.title.clone(),
527            },
528            ClassExtension::Serial(r) => match (&r.title, &r.short_title) {
529                (Some(Title::Single(long)), Some(short)) => {
530                    Some(Title::Shorthand(short.clone(), long.clone()))
531                }
532                _ => r.title.clone(),
533            },
534            ClassExtension::LegalCase(r) => r.title.clone(),
535            ClassExtension::Statute(r) => r.title.clone(),
536            ClassExtension::Treaty(r) => r.title.clone(),
537            ClassExtension::Hearing(r) => r.title.clone(),
538            ClassExtension::Regulation(r) => r.title.clone(),
539            ClassExtension::Brief(r) => r.title.clone(),
540            ClassExtension::Classic(r) => r.title.clone(),
541            ClassExtension::Patent(r) => r.title.clone(),
542            ClassExtension::Dataset(r) => r.title.clone(),
543            ClassExtension::Standard(r) => r.title.clone(),
544            ClassExtension::Software(r) => r.title.clone(),
545            ClassExtension::Event(r) => r.title.clone(),
546            ClassExtension::AudioVisual(r) => match (&r.core.title, &r.core.short_title) {
547                (Some(Title::Single(long)), Some(short)) => {
548                    Some(Title::Shorthand(short.clone(), long.clone()))
549                }
550                _ => r.core.title.clone(),
551            },
552            ClassExtension::Unknown(data) => data
553                .fields
554                .get("title")
555                .and_then(JsonValue::as_str)
556                .map(|title| Title::Single(title.to_string())),
557        }
558    }
559
560    /// Return the title of an individual volume.
561    pub fn volume_title(&self) -> Option<String> {
562        match &self.extension {
563            ClassExtension::Monograph(r) => r.volume_title.clone(),
564            _ => None,
565        }
566    }
567
568    fn non_empty_date(date: DateValue) -> Option<DateValue> {
569        if date.is_empty() { None } else { Some(date) }
570    }
571
572    /// Return the creation or origination date.
573    pub fn created(&self) -> Option<DateValue> {
574        match &self.extension {
575            ClassExtension::Monograph(r) => Self::non_empty_date(r.created.clone()),
576            ClassExtension::CollectionComponent(r) => Self::non_empty_date(r.created.clone()),
577            ClassExtension::SerialComponent(r) => Self::non_empty_date(r.created.clone()),
578            ClassExtension::Collection(r) => Self::non_empty_date(r.created.clone()),
579            ClassExtension::Serial(_) => None,
580            ClassExtension::LegalCase(r) => Self::non_empty_date(r.created.clone()),
581            ClassExtension::Statute(r) => Self::non_empty_date(r.created.clone()),
582            ClassExtension::Treaty(r) => Self::non_empty_date(r.created.clone()),
583            ClassExtension::Hearing(r) => Self::non_empty_date(r.created.clone()),
584            ClassExtension::Regulation(r) => Self::non_empty_date(r.created.clone()),
585            ClassExtension::Brief(r) => Self::non_empty_date(r.created.clone()),
586            ClassExtension::Classic(r) => Self::non_empty_date(r.created.clone()),
587            ClassExtension::Patent(r) => Self::non_empty_date(r.created.clone()),
588            ClassExtension::Dataset(r) => Self::non_empty_date(r.created.clone()),
589            ClassExtension::Standard(r) => Self::non_empty_date(r.created.clone()),
590            ClassExtension::Software(r) => Self::non_empty_date(r.created.clone()),
591            ClassExtension::Event(_) => None,
592            ClassExtension::AudioVisual(r) => Self::non_empty_date(r.core.created.clone()),
593            ClassExtension::Unknown(_) => None,
594        }
595    }
596
597    /// Return the explicit publication or release date.
598    pub fn issued(&self) -> Option<DateValue> {
599        match &self.extension {
600            ClassExtension::Monograph(r) => Self::non_empty_date(r.issued.clone()),
601            ClassExtension::CollectionComponent(r) => Self::non_empty_date(r.issued.clone()),
602            ClassExtension::SerialComponent(r) => Self::non_empty_date(r.issued.clone()),
603            ClassExtension::Collection(r) => Self::non_empty_date(r.issued.clone()),
604            ClassExtension::Serial(_) => None,
605            ClassExtension::LegalCase(r) => Self::non_empty_date(r.issued.clone()),
606            ClassExtension::Statute(r) => Self::non_empty_date(r.issued.clone()),
607            ClassExtension::Treaty(r) => Self::non_empty_date(r.issued.clone()),
608            ClassExtension::Hearing(r) => Self::non_empty_date(r.issued.clone()),
609            ClassExtension::Regulation(r) => Self::non_empty_date(r.issued.clone()),
610            ClassExtension::Brief(r) => Self::non_empty_date(r.issued.clone()),
611            ClassExtension::Classic(r) => Self::non_empty_date(r.issued.clone()),
612            ClassExtension::Patent(r) => Self::non_empty_date(r.issued.clone()),
613            ClassExtension::Dataset(r) => Self::non_empty_date(r.issued.clone()),
614            ClassExtension::Standard(r) => Self::non_empty_date(r.issued.clone()),
615            ClassExtension::Software(r) => Self::non_empty_date(r.issued.clone()),
616            ClassExtension::Event(r) => r.date.clone(),
617            ClassExtension::AudioVisual(r) => Self::non_empty_date(r.core.issued.clone()),
618            ClassExtension::Unknown(_) => None,
619        }
620    }
621
622    /// Return the effective issued date, falling back to the created date when
623    /// no explicit issued date is present.
624    pub fn effective_issued_date(&self) -> Option<DateValue> {
625        self.issued().or_else(|| self.created())
626    }
627
628    /// Return the copyright year, a publication-year substitute used when
629    /// the true issue date is unknown (GB/T 7714 §7.5.4.3's `c1988`).
630    pub fn copyright(&self) -> Option<DateValue> {
631        match &self.extension {
632            ClassExtension::Monograph(r) => r.copyright.clone().and_then(Self::non_empty_date),
633            _ => None,
634        }
635    }
636
637    /// Return the printing/impression year, another publication-year
638    /// substitute (GB/T 7714 §7.5.4.3's `1995印刷`).
639    pub fn printing(&self) -> Option<DateValue> {
640        match &self.extension {
641            ClassExtension::Monograph(r) => r.printing.clone().and_then(Self::non_empty_date),
642            _ => None,
643        }
644    }
645
646    /// Return the DOI.
647    pub fn doi(&self) -> Option<String> {
648        match &self.extension {
649            ClassExtension::Monograph(r) => r.doi.clone(),
650            ClassExtension::CollectionComponent(r) => r.doi.clone(),
651            ClassExtension::SerialComponent(r) => r.doi.clone(),
652            ClassExtension::LegalCase(r) => r.doi.clone(),
653            ClassExtension::Dataset(r) => r.doi.clone(),
654            ClassExtension::Standard(r) => r.doi.clone(),
655            ClassExtension::Software(r) => r.doi.clone(),
656            ClassExtension::AudioVisual(_) => None,
657            _ => None,
658        }
659    }
660
661    /// Return the ADS bibcode.
662    pub fn ads_bibcode(&self) -> Option<String> {
663        match &self.extension {
664            ClassExtension::Monograph(r) => r.ads_bibcode.clone(),
665            ClassExtension::SerialComponent(r) => r.ads_bibcode.clone(),
666            ClassExtension::AudioVisual(_) => None,
667            _ => None,
668        }
669    }
670
671    /// Return the note.
672    pub fn note(&self) -> Option<RichText> {
673        match &self.extension {
674            ClassExtension::Monograph(r) => r.note.clone(),
675            ClassExtension::CollectionComponent(r) => r.note.clone(),
676            ClassExtension::SerialComponent(r) => r.note.clone(),
677            ClassExtension::Collection(r) => r.note.clone(),
678            ClassExtension::Serial(r) => r.note.clone(),
679            ClassExtension::LegalCase(r) => r.note.clone(),
680            ClassExtension::Statute(r) => r.note.clone(),
681            ClassExtension::Treaty(r) => r.note.clone(),
682            ClassExtension::Standard(r) => r.note.clone(),
683            ClassExtension::Event(r) => r.note.clone(),
684            ClassExtension::AudioVisual(r) => r.note.clone(),
685            _ => None,
686        }
687    }
688
689    /// Return the URL.
690    pub fn url(&self) -> Option<Url> {
691        match &self.extension {
692            ClassExtension::Monograph(r) => r.url.clone(),
693            ClassExtension::CollectionComponent(r) => r.url.clone(),
694            ClassExtension::SerialComponent(r) => r.url.clone(),
695            ClassExtension::Collection(r) => r.url.clone(),
696            ClassExtension::Serial(r) => r.url.clone(),
697            ClassExtension::LegalCase(r) => r.url.clone(),
698            ClassExtension::Statute(r) => r.url.clone(),
699            ClassExtension::Treaty(r) => r.url.clone(),
700            ClassExtension::Hearing(r) => r.url.clone(),
701            ClassExtension::Regulation(r) => r.url.clone(),
702            ClassExtension::Brief(r) => r.url.clone(),
703            ClassExtension::Classic(r) => r.url.clone(),
704            ClassExtension::Patent(r) => r.url.clone(),
705            ClassExtension::Dataset(r) => r.url.clone(),
706            ClassExtension::Standard(r) => r.url.clone(),
707            ClassExtension::Software(r) => r.url.clone(),
708            ClassExtension::Event(r) => r.url.clone(),
709            ClassExtension::AudioVisual(r) => r.url.clone(),
710            ClassExtension::Unknown(_) => None,
711        }
712    }
713
714    /// Return the publisher place.
715    pub fn publisher_place(&self) -> Option<String> {
716        match &self.extension {
717            ClassExtension::Monograph(r) => r
718                .publisher
719                .as_ref()
720                .and_then(|p| p.place.clone().map(Into::into))
721                .or_else(|| {
722                    r.container.as_ref().and_then(|c| match c {
723                        WorkRelation::Embedded(p) => p.publisher_place(),
724                        _ => None,
725                    })
726                }),
727            ClassExtension::CollectionComponent(r) => r.container.as_ref().and_then(|c| match c {
728                WorkRelation::Embedded(p) => p.publisher_place(),
729                _ => None,
730            }),
731            ClassExtension::SerialComponent(r) => r.container.as_ref().and_then(|c| match c {
732                WorkRelation::Embedded(p) => p.publisher_place(),
733                _ => None,
734            }),
735            ClassExtension::Collection(r) => r
736                .publisher
737                .as_ref()
738                .and_then(|p| p.place.clone().map(Into::into))
739                .or_else(|| {
740                    r.container.as_ref().and_then(|c| match c {
741                        WorkRelation::Embedded(p) => p.publisher_place(),
742                        _ => None,
743                    })
744                }),
745            ClassExtension::Serial(_) => None,
746            ClassExtension::Classic(r) => r
747                .publisher
748                .as_ref()
749                .and_then(|p| p.place.clone().map(Into::into)),
750            ClassExtension::Dataset(r) => r
751                .publisher
752                .as_ref()
753                .and_then(|p| p.place.clone().map(Into::into)),
754            ClassExtension::Standard(r) => r
755                .publisher
756                .as_ref()
757                .and_then(|p| p.place.clone().map(Into::into)),
758            ClassExtension::Software(r) => r
759                .publisher
760                .as_ref()
761                .and_then(|p| p.place.clone().map(Into::into)),
762            ClassExtension::Event(r) => r.location.clone(),
763            ClassExtension::AudioVisual(r) => r
764                .publisher
765                .as_ref()
766                .and_then(|p| p.place.clone().map(Into::into)),
767            _ => None,
768        }
769    }
770
771    /// Return the publisher as a string.
772    pub fn publisher_str(&self) -> Option<String> {
773        match &self.extension {
774            ClassExtension::Monograph(r) => r
775                .publisher
776                .as_ref()
777                .map(|p| p.name.to_string())
778                .or_else(|| {
779                    r.container.as_ref().and_then(|c| match c {
780                        WorkRelation::Embedded(p) => p.publisher_str(),
781                        _ => None,
782                    })
783                }),
784            ClassExtension::CollectionComponent(r) => r.container.as_ref().and_then(|c| match c {
785                WorkRelation::Embedded(p) => p.publisher_str(),
786                _ => None,
787            }),
788            ClassExtension::SerialComponent(r) => r.container.as_ref().and_then(|c| match c {
789                WorkRelation::Embedded(p) => p.publisher_str(),
790                _ => None,
791            }),
792            ClassExtension::Collection(r) => r
793                .publisher
794                .as_ref()
795                .map(|p| p.name.to_string())
796                .or_else(|| {
797                    r.container.as_ref().and_then(|c| match c {
798                        WorkRelation::Embedded(p) => p.publisher_str(),
799                        _ => None,
800                    })
801                }),
802            ClassExtension::Serial(r) => r.publisher.as_ref().map(|p| p.name.to_string()),
803            ClassExtension::Classic(r) => r.publisher.as_ref().map(|p| p.name.to_string()),
804            ClassExtension::Dataset(r) => r.publisher.as_ref().map(|p| p.name.to_string()),
805            ClassExtension::Standard(r) => r.publisher.as_ref().map(|p| p.name.to_string()),
806            ClassExtension::Software(r) => r.publisher.as_ref().map(|p| p.name.to_string()),
807            ClassExtension::Event(r) => r.network.clone(),
808            ClassExtension::AudioVisual(r) => r.publisher.as_ref().map(|p| p.name.to_string()),
809            _ => None,
810        }
811    }
812
813    /// Normalize genre/medium values to canonical kebab-case (defensive fallback for legacy producers).
814    ///
815    /// Converts to ASCII lowercase and replaces whitespace/underscores with dashes.
816    pub(super) fn normalize_genre_medium(s: &str) -> String {
817        let lower = s.to_ascii_lowercase();
818        lower
819            .split(|c: char| c.is_whitespace() || c == '_')
820            .filter(|p| !p.is_empty())
821            .collect::<Vec<_>>()
822            .join("-")
823    }
824
825    /// Return the genre/type as string, normalized to canonical kebab-case.
826    pub fn genre(&self) -> Option<String> {
827        match &self.extension {
828            ClassExtension::Monograph(r) => {
829                r.genre.as_ref().map(|g| Self::normalize_genre_medium(g))
830            }
831            ClassExtension::CollectionComponent(r) => {
832                r.genre.as_ref().map(|g| Self::normalize_genre_medium(g))
833            }
834            ClassExtension::SerialComponent(r) => {
835                r.genre.as_ref().map(|g| Self::normalize_genre_medium(g))
836            }
837            ClassExtension::Event(r) => r.genre.as_ref().map(|g| Self::normalize_genre_medium(g)),
838            ClassExtension::AudioVisual(r) => r
839                .core
840                .genre
841                .as_ref()
842                .map(|g| Self::normalize_genre_medium(g)),
843            ClassExtension::Dataset(r) => r.genre.as_ref().map(|g| Self::normalize_genre_medium(g)),
844            _ => None,
845        }
846    }
847
848    /// Return the archive or repository name.
849    pub fn archive(&self) -> Option<String> {
850        match &self.extension {
851            ClassExtension::Monograph(r) => r.archive.clone(),
852            _ => None,
853        }
854    }
855
856    /// Return the archive shelfmark or repository location.
857    pub fn archive_location(&self) -> Option<String> {
858        match &self.extension {
859            ClassExtension::Monograph(r) => r
860                .archive_info
861                .as_ref()
862                .and_then(|info| info.location.clone())
863                .or_else(|| r.archive_location.clone()),
864            ClassExtension::CollectionComponent(r) => {
865                r.archive_info.as_ref().and_then(|i| i.location.clone())
866            }
867            ClassExtension::SerialComponent(r) => {
868                r.archive_info.as_ref().and_then(|i| i.location.clone())
869            }
870            _ => None,
871        }
872    }
873
874    /// Return the archive name from structured ArchiveInfo.
875    pub fn archive_name(&self) -> Option<MultilingualString> {
876        match &self.extension {
877            ClassExtension::Monograph(r) => r.archive_info.as_ref().and_then(|i| i.name.clone()),
878            ClassExtension::CollectionComponent(r) => {
879                r.archive_info.as_ref().and_then(|i| i.name.clone())
880            }
881            ClassExtension::SerialComponent(r) => {
882                r.archive_info.as_ref().and_then(|i| i.name.clone())
883            }
884            _ => None,
885        }
886    }
887
888    /// Return the archive geographic place from structured ArchiveInfo.
889    pub fn archive_place(&self) -> Option<String> {
890        match &self.extension {
891            ClassExtension::Monograph(r) => r
892                .archive_info
893                .as_ref()
894                .and_then(|i| i.place.clone().map(Into::into)),
895            ClassExtension::CollectionComponent(r) => r
896                .archive_info
897                .as_ref()
898                .and_then(|i| i.place.clone().map(Into::into)),
899            ClassExtension::SerialComponent(r) => r
900                .archive_info
901                .as_ref()
902                .and_then(|i| i.place.clone().map(Into::into)),
903            _ => None,
904        }
905    }
906
907    /// Return the archive collection name from structured ArchiveInfo.
908    pub fn archive_collection(&self) -> Option<String> {
909        match &self.extension {
910            ClassExtension::Monograph(r) => {
911                r.archive_info.as_ref().and_then(|i| i.collection.clone())
912            }
913            ClassExtension::CollectionComponent(r) => {
914                r.archive_info.as_ref().and_then(|i| i.collection.clone())
915            }
916            ClassExtension::SerialComponent(r) => {
917                r.archive_info.as_ref().and_then(|i| i.collection.clone())
918            }
919            _ => None,
920        }
921    }
922
923    /// Return the archive collection identifier from structured ArchiveInfo.
924    pub fn archive_collection_id(&self) -> Option<String> {
925        match &self.extension {
926            ClassExtension::Monograph(r) => r
927                .archive_info
928                .as_ref()
929                .and_then(|i| i.collection_id.clone()),
930            ClassExtension::CollectionComponent(r) => r
931                .archive_info
932                .as_ref()
933                .and_then(|i| i.collection_id.clone()),
934            ClassExtension::SerialComponent(r) => r
935                .archive_info
936                .as_ref()
937                .and_then(|i| i.collection_id.clone()),
938            _ => None,
939        }
940    }
941
942    /// Return the archive series from structured ArchiveInfo.
943    pub fn archive_series(&self) -> Option<String> {
944        match &self.extension {
945            ClassExtension::Monograph(r) => r.archive_info.as_ref().and_then(|i| i.series.clone()),
946            ClassExtension::CollectionComponent(r) => {
947                r.archive_info.as_ref().and_then(|i| i.series.clone())
948            }
949            ClassExtension::SerialComponent(r) => {
950                r.archive_info.as_ref().and_then(|i| i.series.clone())
951            }
952            _ => None,
953        }
954    }
955
956    /// Return the archive box number from structured ArchiveInfo.
957    pub fn archive_box(&self) -> Option<String> {
958        match &self.extension {
959            ClassExtension::Monograph(r) => r.archive_info.as_ref().and_then(|i| i.r#box.clone()),
960            ClassExtension::CollectionComponent(r) => {
961                r.archive_info.as_ref().and_then(|i| i.r#box.clone())
962            }
963            ClassExtension::SerialComponent(r) => {
964                r.archive_info.as_ref().and_then(|i| i.r#box.clone())
965            }
966            _ => None,
967        }
968    }
969
970    /// Return the archive folder from structured ArchiveInfo.
971    pub fn archive_folder(&self) -> Option<String> {
972        match &self.extension {
973            ClassExtension::Monograph(r) => r.archive_info.as_ref().and_then(|i| i.folder.clone()),
974            ClassExtension::CollectionComponent(r) => {
975                r.archive_info.as_ref().and_then(|i| i.folder.clone())
976            }
977            ClassExtension::SerialComponent(r) => {
978                r.archive_info.as_ref().and_then(|i| i.folder.clone())
979            }
980            _ => None,
981        }
982    }
983
984    /// Return the archive item identifier from structured ArchiveInfo.
985    pub fn archive_item(&self) -> Option<String> {
986        match &self.extension {
987            ClassExtension::Monograph(r) => r.archive_info.as_ref().and_then(|i| i.item.clone()),
988            ClassExtension::CollectionComponent(r) => {
989                r.archive_info.as_ref().and_then(|i| i.item.clone())
990            }
991            ClassExtension::SerialComponent(r) => {
992                r.archive_info.as_ref().and_then(|i| i.item.clone())
993            }
994            _ => None,
995        }
996    }
997
998    /// Return the archive URL from structured ArchiveInfo.
999    pub fn archive_url(&self) -> Option<Url> {
1000        match &self.extension {
1001            ClassExtension::Monograph(r) => r.archive_info.as_ref().and_then(|i| i.url.clone()),
1002            ClassExtension::CollectionComponent(r) => {
1003                r.archive_info.as_ref().and_then(|i| i.url.clone())
1004            }
1005            ClassExtension::SerialComponent(r) => {
1006                r.archive_info.as_ref().and_then(|i| i.url.clone())
1007            }
1008            _ => None,
1009        }
1010    }
1011
1012    /// Return the publication status.
1013    pub fn status(&self) -> Option<String> {
1014        match &self.extension {
1015            ClassExtension::Monograph(r) => r.status.clone(),
1016            ClassExtension::CollectionComponent(r) => r.status.clone(),
1017            ClassExtension::SerialComponent(r) => r.status.clone(),
1018            ClassExtension::Standard(r) => r.status.clone(),
1019            _ => None,
1020        }
1021    }
1022
1023    /// Return the eprint identifier.
1024    pub fn eprint_id(&self) -> Option<String> {
1025        match &self.extension {
1026            ClassExtension::Monograph(r) => r.eprint.as_ref().map(|e| e.id.clone()),
1027            ClassExtension::CollectionComponent(r) => r.eprint.as_ref().map(|e| e.id.clone()),
1028            ClassExtension::SerialComponent(r) => r.eprint.as_ref().map(|e| e.id.clone()),
1029            _ => None,
1030        }
1031    }
1032
1033    /// Return the eprint server name.
1034    pub fn eprint_server(&self) -> Option<String> {
1035        match &self.extension {
1036            ClassExtension::Monograph(r) => r.eprint.as_ref().map(|e| e.server.clone()),
1037            ClassExtension::CollectionComponent(r) => r.eprint.as_ref().map(|e| e.server.clone()),
1038            ClassExtension::SerialComponent(r) => r.eprint.as_ref().map(|e| e.server.clone()),
1039            _ => None,
1040        }
1041    }
1042
1043    /// Return the eprint subject class.
1044    pub fn eprint_class(&self) -> Option<String> {
1045        match &self.extension {
1046            ClassExtension::Monograph(r) => r.eprint.as_ref().and_then(|e| e.class.clone()),
1047            ClassExtension::CollectionComponent(r) => {
1048                r.eprint.as_ref().and_then(|e| e.class.clone())
1049            }
1050            ClassExtension::SerialComponent(r) => r.eprint.as_ref().and_then(|e| e.class.clone()),
1051            _ => None,
1052        }
1053    }
1054
1055    /// Return the medium, normalized to canonical kebab-case.
1056    pub fn medium(&self) -> Option<String> {
1057        match &self.extension {
1058            ClassExtension::Monograph(r) => {
1059                r.medium.as_ref().map(|m| Self::normalize_genre_medium(m))
1060            }
1061            ClassExtension::CollectionComponent(r) => {
1062                r.medium.as_ref().map(|m| Self::normalize_genre_medium(m))
1063            }
1064            ClassExtension::SerialComponent(r) => {
1065                r.medium.as_ref().map(|m| Self::normalize_genre_medium(m))
1066            }
1067            ClassExtension::AudioVisual(r) => {
1068                r.medium.as_ref().map(|m| Self::normalize_genre_medium(m))
1069            }
1070            // Software carries the CSL `medium` value under `platform` (see
1071            // `from_software_ref`); unlike the other classes, GB/T 7714's oracle
1072            // expects the carrier code verbatim (e.g. "Apparatus"), not
1073            // kebab-cased.
1074            ClassExtension::Software(r) => r.platform.clone(),
1075            _ => None,
1076        }
1077    }
1078
1079    /// Return the version.
1080    pub fn version(&self) -> Option<String> {
1081        match &self.extension {
1082            ClassExtension::Monograph(r) => r.version.clone(),
1083            ClassExtension::Dataset(r) => r.version.clone(),
1084            ClassExtension::Software(r) => r.version.clone(),
1085            _ => None,
1086        }
1087    }
1088
1089    /// Return the cartographic scale.
1090    pub fn scale(&self) -> Option<String> {
1091        match &self.extension {
1092            ClassExtension::Monograph(r) => r.scale.clone(),
1093            _ => None,
1094        }
1095    }
1096
1097    /// Return the abstract.
1098    pub fn abstract_text(&self) -> Option<RichText> {
1099        match &self.extension {
1100            ClassExtension::Monograph(r) => r.abstract_text.clone(),
1101            ClassExtension::CollectionComponent(r) => r.abstract_text.clone(),
1102            ClassExtension::SerialComponent(r) => r.abstract_text.clone(),
1103            _ => None,
1104        }
1105    }
1106
1107    /// Return the container-style title for parent works, reporters, or codes.
1108    pub fn container_title(&self) -> Option<Title> {
1109        match &self.extension {
1110            ClassExtension::Monograph(r) => r.container.as_ref().and_then(|c| match c {
1111                WorkRelation::Embedded(p) => p.title().or_else(|| p.container_title()),
1112                WorkRelation::Id(_) => None,
1113            }),
1114            ClassExtension::CollectionComponent(r) => r.container.as_ref().and_then(|c| match c {
1115                WorkRelation::Embedded(p) => p.title().or_else(|| p.container_title()),
1116                WorkRelation::Id(_) => None,
1117            }),
1118            ClassExtension::SerialComponent(r) => r.container.as_ref().and_then(|c| match c {
1119                WorkRelation::Embedded(p) => p.title().or_else(|| p.container_title()),
1120                WorkRelation::Id(_) => None,
1121            }),
1122            ClassExtension::Serial(r) => r.container.as_ref().and_then(|c| match c {
1123                WorkRelation::Embedded(p) => p.title().or_else(|| p.container_title()),
1124                WorkRelation::Id(_) => None,
1125            }),
1126            ClassExtension::LegalCase(r) => r.reporter.clone().map(Title::Single),
1127            ClassExtension::Statute(r) => r.code.clone().map(Title::Single),
1128            ClassExtension::Regulation(r) => r.code.clone().map(Title::Single),
1129            ClassExtension::Treaty(r) => r.reporter.clone().map(Title::Single),
1130            ClassExtension::Event(r) => r.container.as_ref().and_then(|c| match c {
1131                WorkRelation::Embedded(p) => p.title().or_else(|| p.container_title()),
1132                WorkRelation::Id(_) => None,
1133            }),
1134            ClassExtension::AudioVisual(r) => r.container.as_ref().and_then(|c| match c {
1135                WorkRelation::Embedded(p) => p.title().or_else(|| p.container_title()),
1136                WorkRelation::Id(_) => None,
1137            }),
1138            _ => None,
1139        }
1140    }
1141
1142    /// Return the series or collection title for a parent work.
1143    pub fn collection_title(&self) -> Option<Title> {
1144        fn nested_collection_title(relation: &WorkRelation) -> Option<Title> {
1145            let WorkRelation::Embedded(parent) = relation else {
1146                return None;
1147            };
1148            let container = match parent.extension() {
1149                ClassExtension::Monograph(r) => r.container.as_ref(),
1150                ClassExtension::Collection(r) => r.container.as_ref(),
1151                ClassExtension::Serial(r) => r.container.as_ref(),
1152                _ => None,
1153            }?;
1154            match container {
1155                WorkRelation::Embedded(collection) => collection.title(),
1156                WorkRelation::Id(_) => None,
1157            }
1158        }
1159
1160        match &self.extension {
1161            ClassExtension::Monograph(r) => r.container.as_ref().and_then(nested_collection_title),
1162            ClassExtension::Collection(r) => {
1163                r.container.as_ref().and_then(|relation| match relation {
1164                    WorkRelation::Embedded(collection) => collection.title(),
1165                    WorkRelation::Id(_) => None,
1166                })
1167            }
1168            ClassExtension::CollectionComponent(r) => {
1169                r.container.as_ref().and_then(nested_collection_title)
1170            }
1171            ClassExtension::SerialComponent(r) => {
1172                r.container.as_ref().and_then(nested_collection_title)
1173            }
1174            _ => None,
1175        }
1176    }
1177
1178    /// Return the volume.
1179    pub fn volume(&self) -> Option<NumOrStr> {
1180        match &self.extension {
1181            ClassExtension::Monograph(r) => r
1182                .volume
1183                .clone()
1184                .or_else(|| self.find_numbering(NumberingType::Volume))
1185                .map(NumOrStr::Str),
1186            ClassExtension::Collection(r) => r
1187                .volume
1188                .clone()
1189                .or_else(|| self.find_numbering(NumberingType::Volume))
1190                .map(NumOrStr::Str),
1191            ClassExtension::CollectionComponent(r) => r
1192                .volume
1193                .clone()
1194                .or_else(|| self.find_numbering(NumberingType::Volume))
1195                .or_else(|| {
1196                    r.container.as_ref().and_then(|container| match container {
1197                        WorkRelation::Embedded(parent) => {
1198                            parent.volume().map(|volume| volume.to_string())
1199                        }
1200                        WorkRelation::Id(_) => None,
1201                    })
1202                })
1203                .map(NumOrStr::Str),
1204            ClassExtension::SerialComponent(r) => r
1205                .volume
1206                .clone()
1207                .or_else(|| self.find_numbering(NumberingType::Volume))
1208                .map(NumOrStr::Str),
1209            ClassExtension::Classic(r) => r
1210                .volume
1211                .clone()
1212                .or_else(|| self.find_numbering(NumberingType::Volume))
1213                .map(NumOrStr::Str),
1214            ClassExtension::LegalCase(r) => r.volume.clone().map(NumOrStr::Str),
1215            ClassExtension::Statute(r) => r.volume.clone().map(NumOrStr::Str),
1216            ClassExtension::Treaty(r) => r.volume.clone().map(NumOrStr::Str),
1217            ClassExtension::Regulation(r) => r.volume.clone().map(NumOrStr::Str),
1218            _ => self
1219                .find_numbering(NumberingType::Volume)
1220                .map(NumOrStr::Str),
1221        }
1222    }
1223
1224    /// Return the collection number (series number).
1225    pub fn collection_number(&self) -> Option<String> {
1226        match &self.extension {
1227            ClassExtension::CollectionComponent(r) => r.container.as_ref().and_then(|c| match c {
1228                WorkRelation::Embedded(p) => p.collection_number(),
1229                WorkRelation::Id(_) => None,
1230            }),
1231            _ => self.find_numbering(NumberingType::Volume),
1232        }
1233    }
1234
1235    /// Return the issue.
1236    pub fn issue(&self) -> Option<NumOrStr> {
1237        match &self.extension {
1238            ClassExtension::Monograph(r) => r
1239                .issue
1240                .clone()
1241                .or_else(|| self.find_numbering(NumberingType::Issue))
1242                .map(NumOrStr::Str),
1243            ClassExtension::Collection(r) => r
1244                .issue
1245                .clone()
1246                .or_else(|| self.find_numbering(NumberingType::Issue))
1247                .map(NumOrStr::Str),
1248            ClassExtension::CollectionComponent(r) => r
1249                .issue
1250                .clone()
1251                .or_else(|| self.find_numbering(NumberingType::Issue))
1252                .map(NumOrStr::Str),
1253            ClassExtension::SerialComponent(r) => r
1254                .issue
1255                .clone()
1256                .or_else(|| self.find_numbering(NumberingType::Issue))
1257                .map(NumOrStr::Str),
1258            ClassExtension::Classic(r) => r
1259                .issue
1260                .clone()
1261                .or_else(|| self.find_numbering(NumberingType::Issue))
1262                .map(NumOrStr::Str),
1263            _ => self.find_numbering(NumberingType::Issue).map(NumOrStr::Str),
1264        }
1265    }
1266
1267    /// Return the pages.
1268    pub fn pages(&self) -> Option<NumOrStr> {
1269        match &self.extension {
1270            ClassExtension::Monograph(r) => r.pages.clone(),
1271            ClassExtension::CollectionComponent(r) => r.pages.clone(),
1272            ClassExtension::SerialComponent(r) => r.pages.clone().map(NumOrStr::Str),
1273            ClassExtension::LegalCase(r) => r.page.clone().map(NumOrStr::Str),
1274            ClassExtension::Statute(r) => r.page.clone().map(NumOrStr::Str),
1275            ClassExtension::Treaty(r) => r.page.clone().map(NumOrStr::Str),
1276            ClassExtension::Patent(r) => r.pages.clone().map(NumOrStr::Str),
1277            _ => None,
1278        }
1279    }
1280
1281    /// Return the authority (court, legislative body, standards org, etc.).
1282    pub fn authority(&self) -> Option<String> {
1283        match &self.extension {
1284            ClassExtension::LegalCase(r) => r.authority.clone(),
1285            ClassExtension::Statute(r) => r.authority.clone(),
1286            ClassExtension::Hearing(r) => r.authority.clone(),
1287            ClassExtension::Regulation(r) => r.authority.clone(),
1288            ClassExtension::Brief(r) => r.authority.clone(),
1289            ClassExtension::Patent(r) => r.authority.clone(),
1290            ClassExtension::Standard(r) => r.authority.clone(),
1291            _ => None,
1292        }
1293    }
1294
1295    /// Return the reporter (legal reporter series).
1296    pub fn reporter(&self) -> Option<String> {
1297        match &self.extension {
1298            ClassExtension::LegalCase(r) => r.reporter.clone(),
1299            ClassExtension::Treaty(r) => r.reporter.clone(),
1300            _ => None,
1301        }
1302    }
1303
1304    /// Return the code (legal code abbreviation).
1305    pub fn code(&self) -> Option<String> {
1306        match &self.extension {
1307            ClassExtension::Statute(r) => r.code.clone(),
1308            ClassExtension::Regulation(r) => r.code.clone(),
1309            _ => None,
1310        }
1311    }
1312
1313    /// Return the section identifier for legal, classic, or serial-component references.
1314    pub fn section(&self) -> Option<String> {
1315        match &self.extension {
1316            ClassExtension::Statute(r) => r.section.clone(),
1317            ClassExtension::Regulation(r) => r.section.clone(),
1318            ClassExtension::Classic(_) => self.find_numbering(NumberingType::Section),
1319            ClassExtension::SerialComponent(r) => r.section.clone(),
1320            _ => None,
1321        }
1322    }
1323
1324    /// Return the generic document number.
1325    pub fn number(&self) -> Option<String> {
1326        match &self.extension {
1327            ClassExtension::Monograph(r) => r
1328                .number
1329                .clone()
1330                .or_else(|| self.find_numbering(NumberingType::Number)),
1331            ClassExtension::Statute(r) => r.number.clone(),
1332            ClassExtension::Hearing(r) => r.session_number.clone(),
1333            ClassExtension::Collection(r) => r
1334                .number
1335                .clone()
1336                .or_else(|| self.find_numbering(NumberingType::Number)),
1337            ClassExtension::CollectionComponent(r) => r
1338                .number
1339                .clone()
1340                .or_else(|| self.find_numbering(NumberingType::Number)),
1341            ClassExtension::SerialComponent(r) => r
1342                .number
1343                .clone()
1344                .or_else(|| self.find_numbering(NumberingType::Number)),
1345            ClassExtension::Classic(r) => r
1346                .number
1347                .clone()
1348                .or_else(|| self.find_numbering(NumberingType::Number)),
1349            _ => self.find_numbering(NumberingType::Number),
1350        }
1351    }
1352
1353    /// Return the report identifier.
1354    pub fn report_number(&self) -> Option<String> {
1355        match &self.extension {
1356            ClassExtension::Monograph(_) => self.find_numbering(NumberingType::Report),
1357            _ => None,
1358        }
1359    }
1360
1361    /// Return the edition.
1362    pub fn edition(&self) -> Option<String> {
1363        match &self.extension {
1364            ClassExtension::Monograph(r) => r
1365                .edition
1366                .clone()
1367                .or_else(|| self.find_numbering(NumberingType::Edition)),
1368            ClassExtension::Collection(r) => r
1369                .edition
1370                .clone()
1371                .or_else(|| self.find_numbering(NumberingType::Edition)),
1372            ClassExtension::CollectionComponent(r) => r
1373                .edition
1374                .clone()
1375                .or_else(|| self.find_numbering(NumberingType::Edition)),
1376            ClassExtension::SerialComponent(r) => r
1377                .edition
1378                .clone()
1379                .or_else(|| self.find_numbering(NumberingType::Edition)),
1380            ClassExtension::Classic(r) => r
1381                .edition
1382                .clone()
1383                .or_else(|| self.find_numbering(NumberingType::Edition)),
1384            _ => self.find_numbering(NumberingType::Edition),
1385        }
1386    }
1387
1388    /// Return the accessed date.
1389    pub fn accessed(&self) -> Option<DateValue> {
1390        class_dispatch!(&self.extension, |r| r.accessed.clone(), unknown(_) => None)
1391    }
1392
1393    /// Return the forward-compat `unknown_fields` captured for this reference.
1394    ///
1395    /// Returns `None` for unknown-class references: their fields are kept
1396    /// wholesale in [`UnknownClassData::fields`] and reported separately via
1397    /// the unknown-class warning.
1398    pub fn unknown_fields(&self) -> Option<&std::collections::BTreeMap<String, JsonValue>> {
1399        class_dispatch!(&self.extension, |r| Some(&r.unknown_fields), unknown(_) => None)
1400    }
1401
1402    /// Return the embedded inline reference behind `original` if any.
1403    ///
1404    /// All 16 reference classes that carry an `original` relation expose it via
1405    /// the same `WorkRelation` shape (only `AudioVisualWork` nests it through
1406    /// `core`). Centralising the dispatch here lets each `original_*` accessor
1407    /// stay a one-liner.
1408    fn original_embedded(&self) -> Option<&InputReference> {
1409        let relation = match &self.extension {
1410            ClassExtension::Monograph(r) => r.original.as_ref(),
1411            ClassExtension::CollectionComponent(r) => r.original.as_ref(),
1412            ClassExtension::SerialComponent(r) => r.original.as_ref(),
1413            ClassExtension::LegalCase(r) => r.original.as_ref(),
1414            ClassExtension::Statute(r) => r.original.as_ref(),
1415            ClassExtension::Treaty(r) => r.original.as_ref(),
1416            ClassExtension::Hearing(r) => r.original.as_ref(),
1417            ClassExtension::Regulation(r) => r.original.as_ref(),
1418            ClassExtension::Brief(r) => r.original.as_ref(),
1419            ClassExtension::Classic(r) => r.original.as_ref(),
1420            ClassExtension::Patent(r) => r.original.as_ref(),
1421            ClassExtension::Dataset(r) => r.original.as_ref(),
1422            ClassExtension::Standard(r) => r.original.as_ref(),
1423            ClassExtension::Software(r) => r.original.as_ref(),
1424            ClassExtension::Event(r) => r.original.as_ref(),
1425            ClassExtension::AudioVisual(r) => r.core.original.as_ref(),
1426            _ => None,
1427        }?;
1428        match relation {
1429            WorkRelation::Embedded(p) => Some(p),
1430            WorkRelation::Id(_) => None,
1431        }
1432    }
1433
1434    /// Return the original publication date.
1435    pub fn original_date(&self) -> Option<DateValue> {
1436        self.original_embedded()?.effective_issued_date()
1437    }
1438
1439    /// Return the original title.
1440    pub fn original_title(&self) -> Option<Title> {
1441        self.original_embedded()?.title()
1442    }
1443
1444    /// Return the original publisher as a string.
1445    pub fn original_publisher_str(&self) -> Option<String> {
1446        self.original_embedded()?
1447            .publisher_str()
1448            .filter(|value| !value.is_empty())
1449    }
1450
1451    /// Return the original publisher place.
1452    pub fn original_publisher_place(&self) -> Option<String> {
1453        self.original_embedded()?.publisher_place()
1454    }
1455
1456    /// Return the ISBN.
1457    pub fn isbn(&self) -> Option<String> {
1458        match &self.extension {
1459            ClassExtension::Monograph(r) => r.isbn.clone(),
1460            _ => None,
1461        }
1462    }
1463
1464    /// Return the ISSN.
1465    pub fn issn(&self) -> Option<String> {
1466        match &self.extension {
1467            ClassExtension::SerialComponent(r) => r.container.as_ref().and_then(|c| match c {
1468                WorkRelation::Embedded(s) => s.issn(),
1469                WorkRelation::Id(_) => None,
1470            }),
1471            ClassExtension::Serial(r) => r.issn.clone(),
1472            _ => None,
1473        }
1474    }
1475
1476    /// Return the Keywords.
1477    pub fn keywords(&self) -> Option<Vec<String>> {
1478        match &self.extension {
1479            ClassExtension::Monograph(r) => r.keywords.clone(),
1480            ClassExtension::CollectionComponent(r) => r.keywords.clone(),
1481            ClassExtension::SerialComponent(r) => r.keywords.clone(),
1482            ClassExtension::Collection(r) => r.keywords.clone(),
1483            ClassExtension::Serial(_) => None,
1484            ClassExtension::LegalCase(r) => r.keywords.clone(),
1485            ClassExtension::Statute(r) => r.keywords.clone(),
1486            ClassExtension::Treaty(r) => r.keywords.clone(),
1487            ClassExtension::Hearing(r) => r.keywords.clone(),
1488            ClassExtension::Regulation(r) => r.keywords.clone(),
1489            ClassExtension::Brief(r) => r.keywords.clone(),
1490            ClassExtension::Classic(r) => r.keywords.clone(),
1491            ClassExtension::Patent(r) => r.keywords.clone(),
1492            ClassExtension::Dataset(r) => r.keywords.clone(),
1493            ClassExtension::Standard(r) => r.keywords.clone(),
1494            ClassExtension::Software(r) => r.keywords.clone(),
1495            ClassExtension::Event(_) => None,
1496            ClassExtension::AudioVisual(_) => None,
1497            ClassExtension::Unknown(_) => None,
1498        }
1499    }
1500
1501    /// Return the language.
1502    pub fn language(&self) -> Option<LangID> {
1503        class_dispatch!(
1504            &self.extension,
1505            |r| r.language.clone(),
1506            audio_visual(r) => r.core.language.clone(),
1507            unknown(_) => None
1508        )
1509    }
1510
1511    /// Return field-level language overrides.
1512    pub fn field_languages(&self) -> &FieldLanguageMap {
1513        class_dispatch!(
1514            &self.extension,
1515            |r| &r.field_languages,
1516            unknown(_) => &EMPTY_FIELD_LANGUAGES
1517        )
1518    }
1519
1520    /// Set the reference ID on the class-specific extension.
1521    ///
1522    /// For unknown-class references the id is stored as a `JsonValue::String`
1523    /// inside `UnknownClassData::fields["id"]`. The wire schema requires
1524    /// `id: string`, so round-trip is lossless for valid inputs.
1525    pub fn set_id(&mut self, id: impl Into<RefID>) {
1526        let id = id.into();
1527        class_dispatch!(&mut self.extension, |r| r.id = Some(id.clone()), unknown(data) => {
1528            data.fields
1529                .insert("id".to_string(), JsonValue::String(id.to_string()));
1530        });
1531    }
1532
1533    /// Return the reference type as a string (CSL-compatible).
1534    pub fn ref_type(&self) -> String {
1535        match &self.extension {
1536            ClassExtension::Monograph(r) => self.monograph_ref_type(r),
1537            ClassExtension::CollectionComponent(r) => collection_component_ref_type(r),
1538            ClassExtension::SerialComponent(r) => serial_component_ref_type(r),
1539            ClassExtension::Collection(r) => match r.r#type {
1540                CollectionType::EditedBook => "book",
1541                _ => "collection",
1542            }
1543            .to_string(),
1544            ClassExtension::Serial(r) => match r.r#type {
1545                SerialType::AcademicJournal => "article-journal",
1546                SerialType::Magazine => "article-magazine",
1547                SerialType::Newspaper => "article-newspaper",
1548                SerialType::BroadcastProgram => "broadcast",
1549                _ => "serial",
1550            }
1551            .to_string(),
1552            ClassExtension::LegalCase(_) => "legal-case".to_string(),
1553            ClassExtension::Statute(_) => "statute".to_string(),
1554            ClassExtension::Treaty(_) => "treaty".to_string(),
1555            ClassExtension::Hearing(_) => "hearing".to_string(),
1556            ClassExtension::Regulation(_) => "regulation".to_string(),
1557            ClassExtension::Brief(_) => "brief".to_string(),
1558            ClassExtension::Classic(_) => "classic".to_string(),
1559            ClassExtension::Patent(_) => "patent".to_string(),
1560            ClassExtension::Dataset(_) => "dataset".to_string(),
1561            ClassExtension::Standard(_) => "standard".to_string(),
1562            ClassExtension::Software(_) => "software".to_string(),
1563            ClassExtension::Event(r) => event_ref_type(r).to_string(),
1564            ClassExtension::AudioVisual(r) => audio_visual_ref_type(&r.r#type).to_string(),
1565            ClassExtension::Unknown(data) => {
1566                // Unknown classes round-trip but cannot route to a known CSL
1567                // ref-type; the engine has no template branch for the raw
1568                // class string, so rendering will fall through to the default
1569                // path (typically empty output).
1570                //
1571                // TODO(csl26-1bdr): Layer 5 `CompatibilityWarning` plumbing
1572                // will surface this as a soft-degrade warning rather than
1573                // silent fall-through. Until then we return the raw class
1574                // string so debug builds and logs can identify the value.
1575                debug_assert!(
1576                    !ReferenceClass::KNOWN.contains(&data.class.as_str()),
1577                    "ClassExtension::Unknown should never wrap a known class string (got `{}`)",
1578                    data.class
1579                );
1580                data.class.clone()
1581            }
1582        }
1583    }
1584
1585    fn monograph_ref_type(&self, r: &Monograph) -> String {
1586        match r.r#type {
1587            MonographType::Book => {
1588                if r.genre
1589                    .as_deref()
1590                    .is_some_and(|genre| genre.eq_ignore_ascii_case("musical_score"))
1591                {
1592                    return "musical-score".to_string();
1593                }
1594                if r.genre.as_deref().is_some_and(|genre| genre == "pamphlet") {
1595                    return "pamphlet".to_string();
1596                }
1597                if r.medium
1598                    .as_deref()
1599                    .is_some_and(|m| m.to_ascii_lowercase().contains("interview"))
1600                {
1601                    "interview"
1602                } else {
1603                    "book"
1604                }
1605                .to_string()
1606            }
1607            MonographType::Manual => "manual".to_string(),
1608            MonographType::Report => "report".to_string(),
1609            MonographType::Thesis => "thesis".to_string(),
1610            MonographType::Webpage => "webpage".to_string(),
1611            MonographType::Post => if r.genre.as_deref() == Some("post-weblog") {
1612                "post-weblog"
1613            } else {
1614                "post"
1615            }
1616            .to_string(),
1617            MonographType::Interview => "interview".to_string(),
1618            MonographType::Manuscript => "manuscript".to_string(),
1619            MonographType::Preprint => "preprint".to_string(),
1620            MonographType::PersonalCommunication => "personal-communication".to_string(),
1621            MonographType::Document => {
1622                // Case-insensitive: real-world exports carry capitalized
1623                // genre labels (e.g. Zotero's `Map`), and the pre-existing
1624                // map back-map matched them ignoring case; the canonical
1625                // lowercase CSL type string is returned either way.
1626                if let Some(genre) = r.genre.as_deref() {
1627                    let lowered = genre.to_ascii_lowercase();
1628                    if matches!(
1629                        lowered.as_str(),
1630                        "map" | "figure" | "graphic" | "periodical" | "collection"
1631                    ) {
1632                        return lowered;
1633                    }
1634                }
1635                if let Some(genre) = r.genre.as_deref()
1636                    && matches!(genre, "bill-proceeding" | "bill-record")
1637                {
1638                    return genre.to_string();
1639                }
1640                if self.genre().as_deref() == Some("conference-paper") {
1641                    return "paper-conference".to_string();
1642                }
1643                if r.medium
1644                    .as_deref()
1645                    .is_some_and(|m| m.to_ascii_lowercase().contains("interview"))
1646                {
1647                    "interview"
1648                } else {
1649                    "document"
1650                }
1651                .to_string()
1652            }
1653            _ => r.r#type.as_str().to_string(),
1654        }
1655    }
1656
1657    /// Fold legacy contributor shorthands (`author`, `editor`, `translator`)
1658    /// into the canonical `contributors` vec on this reference and, when it has
1659    /// one, on its embedded `container`.
1660    ///
1661    /// Deserialized references are already normalized as part of their
1662    /// `#[serde(from = "...Deser")]` conversion; this exists for references
1663    /// built directly in Rust — such as the biblatex converter — whose
1664    /// shorthand fields are `skip_serializing` and would otherwise be silently
1665    /// dropped on write. Idempotent — safe to call on an already-normalized
1666    /// reference.
1667    ///
1668    /// Recurses into `container` only; `original`, `event`, and `reviewed`
1669    /// relations are not visited.
1670    pub fn normalize_contributors(&mut self) {
1671        let container = match &mut self.extension {
1672            ClassExtension::Monograph(r) => {
1673                r.normalize_contributors();
1674                &mut r.container
1675            }
1676            ClassExtension::Collection(r) => {
1677                r.normalize_contributors();
1678                &mut r.container
1679            }
1680            ClassExtension::Serial(r) => {
1681                r.normalize_contributors();
1682                &mut r.container
1683            }
1684            ClassExtension::CollectionComponent(r) => {
1685                r.normalize_contributors();
1686                &mut r.container
1687            }
1688            ClassExtension::SerialComponent(r) => {
1689                r.normalize_contributors();
1690                &mut r.container
1691            }
1692            _ => return,
1693        };
1694        if let Some(WorkRelation::Embedded(container)) = container {
1695            container.normalize_contributors();
1696        }
1697    }
1698}
1699
1700fn collection_component_ref_type(r: &CollectionComponent) -> String {
1701    match r.r#type {
1702        MonographComponentType::Chapter => match r.genre.as_deref() {
1703            Some("entry-dictionary") => "entry-dictionary",
1704            Some("entry-encyclopedia") => "entry-encyclopedia",
1705            Some("entry") => "entry",
1706            _ => "chapter",
1707        }
1708        .to_string(),
1709        MonographComponentType::Document => "paper-conference".to_string(),
1710        _ => r.r#type.as_str().to_string(),
1711    }
1712}
1713
1714fn serial_component_ref_type(r: &SerialComponent) -> String {
1715    if r.genre.as_deref() == Some("entry-encyclopedia") {
1716        return "entry-encyclopedia".to_string();
1717    }
1718    if let Some(genre) = r.genre.as_deref()
1719        && matches!(genre, "review" | "review-book")
1720    {
1721        return genre.to_string();
1722    }
1723    let container_type = r.container.as_ref().and_then(|c| match c {
1724        WorkRelation::Embedded(p) => Some(p.ref_type()),
1725        WorkRelation::Id(_) => None,
1726    });
1727    match container_type.as_deref() {
1728        Some("article-magazine") => "article-magazine".to_string(),
1729        Some("article-newspaper") => "article-newspaper".to_string(),
1730        Some("broadcast") => if r
1731            .genre
1732            .as_deref()
1733            .is_some_and(|g| g.to_ascii_lowercase().contains("film"))
1734        {
1735            "motion-picture"
1736        } else {
1737            "broadcast"
1738        }
1739        .to_string(),
1740        _ => "article-journal".to_string(),
1741    }
1742}
1743
1744fn event_ref_type(r: &Event) -> &'static str {
1745    let lowered = r.genre.as_deref().map(str::to_ascii_lowercase);
1746    match lowered.as_deref() {
1747        Some(g) if g.contains("conference") || g.contains("paper") => "paper-conference",
1748        Some(g) if g.contains("broadcast") => "broadcast",
1749        Some(g) if g.contains("performance") => "performance",
1750        Some(g) if g.contains("talk") || g.contains("speech") => "speech",
1751        _ => "event",
1752    }
1753}
1754
1755fn audio_visual_ref_type(kind: &AudioVisualType) -> &'static str {
1756    match kind {
1757        AudioVisualType::Film => "motion-picture",
1758        AudioVisualType::Episode | AudioVisualType::Broadcast => "broadcast",
1759        AudioVisualType::Recording => "song",
1760    }
1761}
1762
1763/// Collects contributors with a given role from a slice of entries.
1764///
1765/// Returns `None` if no entries match. A single match returns the contributor
1766/// unwrapped; two or more fold into a [`ContributorList`].
1767fn collect_contributors_by_role(
1768    entries: &[ContributorEntry],
1769    role: &ContributorRole,
1770) -> Option<Contributor> {
1771    let mut matches = entries
1772        .iter()
1773        .filter(|entry| entry.roles.contains(role))
1774        .map(|e| &e.contributor);
1775    let first = matches.next()?;
1776    let Some(second) = matches.next() else {
1777        return Some(first.clone());
1778    };
1779    let list = std::iter::once(first)
1780        .chain(std::iter::once(second))
1781        .chain(matches)
1782        .cloned()
1783        .collect();
1784    Some(Contributor::ContributorList(ContributorList(list)))
1785}
1786
1787#[cfg(test)]
1788#[allow(
1789    clippy::unwrap_used,
1790    clippy::expect_used,
1791    clippy::panic,
1792    clippy::indexing_slicing,
1793    reason = "Panicking is acceptable and often desired in tests."
1794)]
1795mod tests {
1796    use super::*;
1797    use crate::reference::contributor::SimpleName;
1798
1799    #[test]
1800    fn given_monograph_with_embedded_container_when_normalized_then_container_contributors_fold_too()
1801     {
1802        // Regression test: `normalize_contributors` must recurse into `container`
1803        // for every structural type that has one (Monograph/Collection/Serial),
1804        // not just the component types -- a nested container built directly in
1805        // Rust (bypassing deserialization) has the same skip_serializing shorthand
1806        // problem as the top-level reference.
1807        let container_monograph = Monograph {
1808            r#type: MonographType::Book,
1809            editor: Some(Contributor::SimpleName(SimpleName {
1810                name: "Series Editor".into(),
1811                location: None,
1812                short_name: None,
1813            })),
1814            ..Default::default()
1815        };
1816        let outer_monograph = Monograph {
1817            r#type: MonographType::Book,
1818            container: Some(WorkRelation::Embedded(Box::new(InputReference::Monograph(
1819                Box::new(container_monograph),
1820            )))),
1821            ..Default::default()
1822        };
1823        let mut reference = InputReference::Monograph(Box::new(outer_monograph));
1824
1825        reference.normalize_contributors();
1826
1827        let outer = reference.as_monograph().expect("expected a Monograph");
1828        let container = match outer.container.as_ref().expect("expected a container") {
1829            WorkRelation::Embedded(inner) => inner.as_monograph().expect("expected a Monograph"),
1830            WorkRelation::Id(_) => panic!("expected an embedded container, not an id reference"),
1831        };
1832        assert_eq!(container.contributors.len(), 1);
1833        assert!(
1834            container.contributors[0]
1835                .roles
1836                .contains(&ContributorRole::Editor)
1837        );
1838    }
1839}