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