Skip to main content

citum_schema_style/
style_base.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Style base-inheritance mechanism for named compiled-in styles.
7//!
8//! This module provides [`StyleBase`] — a mechanism for naming
9//! well-known compiled-in styles so that a YAML file can declare
10//! `extends: chicago-notes-18th` and inherit the full style, then override
11//! any fields it needs at the top level of the style document.
12
13use crate::Style;
14use crate::embedded::get_embedded_style;
15#[cfg(feature = "schema")]
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18use std::collections::HashSet;
19
20/// A named, compiled-in style base that serves as an inheritance root.
21///
22/// A style file declares `extends: <key>` to inherit a complete base style.
23/// Any top-level fields in the file (`options`, `citation`, `bibliography`,
24/// etc.) are merged over the base, with local fields taking
25/// ultimate precedence.
26#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[cfg_attr(feature = "schema", derive(JsonSchema))]
28#[serde(rename_all = "kebab-case")]
29#[non_exhaustive]
30pub enum StyleBase {
31    /// Hidden Elsevier Harvard family root.
32    ElsevierHarvardCore,
33    /// Hidden Elsevier with-titles family root.
34    ElsevierWithTitlesCore,
35    /// Hidden Elsevier Vancouver family root.
36    ElsevierVancouverCore,
37    /// Hidden Springer Basic author-date root.
38    SpringerBasicAuthorDateCore,
39    /// Hidden Springer Basic brackets root.
40    SpringerBasicBracketsCore,
41    /// Hidden Springer Vancouver root.
42    SpringerVancouverBracketsCore,
43    /// Hidden Taylor & Francis Chicago root.
44    TaylorAndFrancisChicagoAuthorDateCore,
45    /// Hidden Taylor & Francis CSE root.
46    TaylorAndFrancisCouncilOfScienceEditorsAuthorDateCore,
47    /// Hidden Taylor & Francis NLM root.
48    TaylorAndFrancisNationalLibraryOfMedicineCore,
49    /// Hidden Chicago shortened-notes root.
50    ChicagoShortenedNotesBibliographyCore,
51    /// Chicago Manual of Style 18th edition — notes without bibliography.
52    #[serde(rename = "chicago-notes-18th")]
53    ChicagoNotes18th,
54    /// Chicago Manual of Style 18th edition — author-date system.
55    #[serde(rename = "chicago-author-date-18th")]
56    ChicagoAuthorDate18th,
57    /// Chicago Manual of Style (shortened notes and bibliography).
58    #[serde(rename = "chicago-shortened-notes-bibliography")]
59    ChicagoShortenedNotesBibliography,
60    /// APA 7th edition — author-date system.
61    #[serde(rename = "apa-7th")]
62    Apa7th,
63    /// Elsevier Harvard (author-date).
64    ElsevierHarvard,
65    /// Elsevier with Titles (numeric).
66    ElsevierWithTitles,
67    /// Elsevier Vancouver (numeric).
68    ElsevierVancouver,
69    /// Springer Basic (author-date).
70    SpringerBasicAuthorDate,
71    /// Springer Vancouver Brackets (numeric).
72    SpringerVancouverBrackets,
73    /// Springer Basic Brackets (numeric).
74    SpringerBasicBrackets,
75    /// American Medical Association 11th edition (numeric).
76    AmericanMedicalAssociation,
77    /// Institute of Electrical and Electronics Engineers (numeric).
78    Ieee,
79    /// Taylor & Francis Chicago author-date.
80    TaylorAndFrancisChicagoAuthorDate,
81    /// Taylor & Francis Council of Science Editors author-date.
82    TaylorAndFrancisCouncilOfScienceEditorsAuthorDate,
83    /// Taylor & Francis National Library of Medicine.
84    TaylorAndFrancisNationalLibraryOfMedicine,
85    /// Modern Language Association 9th edition (author-page).
86    ModernLanguageAssociation,
87}
88
89impl StyleBase {
90    /// Return the embedded YAML key used to look up this base.
91    fn embedded_key(&self) -> &'static str {
92        match self {
93            StyleBase::ElsevierHarvardCore => "elsevier-harvard-core",
94            StyleBase::ElsevierWithTitlesCore => "elsevier-with-titles-core",
95            StyleBase::ElsevierVancouverCore => "elsevier-vancouver-core",
96            StyleBase::SpringerBasicAuthorDateCore => "springer-basic-author-date-core",
97            StyleBase::SpringerBasicBracketsCore => "springer-basic-brackets-core",
98            StyleBase::SpringerVancouverBracketsCore => "springer-vancouver-brackets-core",
99            StyleBase::TaylorAndFrancisChicagoAuthorDateCore => {
100                "taylor-and-francis-chicago-author-date-core"
101            }
102            StyleBase::TaylorAndFrancisCouncilOfScienceEditorsAuthorDateCore => {
103                "taylor-and-francis-council-of-science-editors-author-date-core"
104            }
105            StyleBase::TaylorAndFrancisNationalLibraryOfMedicineCore => {
106                "taylor-and-francis-national-library-of-medicine-core"
107            }
108            StyleBase::ChicagoShortenedNotesBibliographyCore => {
109                "chicago-shortened-notes-bibliography-core"
110            }
111            StyleBase::ChicagoNotes18th => "chicago-notes-18th",
112            StyleBase::ChicagoAuthorDate18th => "chicago-author-date-18th",
113            StyleBase::ChicagoShortenedNotesBibliography => "chicago-shortened-notes-bibliography",
114            StyleBase::Apa7th => "apa-7th",
115            StyleBase::ElsevierHarvard => "elsevier-harvard",
116            StyleBase::ElsevierWithTitles => "elsevier-with-titles",
117            StyleBase::ElsevierVancouver => "elsevier-vancouver",
118            StyleBase::SpringerBasicAuthorDate => "springer-basic-author-date",
119            StyleBase::SpringerVancouverBrackets => "springer-vancouver-brackets",
120            StyleBase::SpringerBasicBrackets => "springer-basic-brackets",
121            StyleBase::AmericanMedicalAssociation => "american-medical-association",
122            StyleBase::Ieee => "ieee",
123            StyleBase::TaylorAndFrancisChicagoAuthorDate => {
124                "taylor-and-francis-chicago-author-date"
125            }
126            StyleBase::TaylorAndFrancisCouncilOfScienceEditorsAuthorDate => {
127                "taylor-and-francis-council-of-science-editors-author-date"
128            }
129            StyleBase::TaylorAndFrancisNationalLibraryOfMedicine => {
130                "taylor-and-francis-national-library-of-medicine"
131            }
132            StyleBase::ModernLanguageAssociation => "modern-language-association",
133        }
134    }
135}
136
137/// A reference to a base style, which can be either a named builtin base,
138/// a URI (e.g., `file://...`, `@hub/...`, `https://...`, `git+https://...`),
139/// or a content-addressed identifier (`cid:bafkrei...`).
140#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
141#[cfg_attr(feature = "schema", derive(JsonSchema))]
142#[serde(untagged)]
143pub enum StyleReference {
144    /// A named builtin style base.
145    Base(StyleBase),
146    /// A URI reference to a remote or local style.
147    ///
148    /// Supported schemes: `file://`, `https://`, `git+https://`, `cid:`.
149    Uri(String),
150}
151
152impl StyleReference {
153    /// Returns a string key identifying this reference.
154    pub fn key(&self) -> &str {
155        match self {
156            StyleReference::Base(base) => base.key(),
157            StyleReference::Uri(uri) => uri,
158        }
159    }
160
161    /// Returns true when this reference is a content-addressed CID URI.
162    pub fn is_cid(&self) -> bool {
163        matches!(self, StyleReference::Uri(uri) if uri.starts_with("cid:"))
164    }
165}
166
167impl From<StyleBase> for StyleReference {
168    fn from(base: StyleBase) -> Self {
169        StyleReference::Base(base)
170    }
171}
172
173impl StyleBase {
174    ///
175    /// # Panics
176    ///
177    /// Panics if the embedded YAML is missing or malformed.
178    #[allow(
179        clippy::panic,
180        reason = "Embedded styles must be valid and present at runtime"
181    )]
182    pub fn base(&self) -> Style {
183        let key = self.embedded_key();
184        get_embedded_style(key)
185            .unwrap_or_else(|| panic!("StyleBase: missing embedded style for key '{key}'"))
186            .unwrap_or_else(|e| panic!("StyleBase: malformed embedded YAML for key '{key}': {e}"))
187    }
188
189    /// Return the canonical base key string (kebab-case).
190    pub fn key(&self) -> &'static str {
191        match self {
192            StyleBase::ElsevierHarvardCore => "elsevier-harvard-core",
193            StyleBase::ElsevierWithTitlesCore => "elsevier-with-titles-core",
194            StyleBase::ElsevierVancouverCore => "elsevier-vancouver-core",
195            StyleBase::SpringerBasicAuthorDateCore => "springer-basic-author-date-core",
196            StyleBase::SpringerBasicBracketsCore => "springer-basic-brackets-core",
197            StyleBase::SpringerVancouverBracketsCore => "springer-vancouver-brackets-core",
198            StyleBase::TaylorAndFrancisChicagoAuthorDateCore => {
199                "taylor-and-francis-chicago-author-date-core"
200            }
201            StyleBase::TaylorAndFrancisCouncilOfScienceEditorsAuthorDateCore => {
202                "taylor-and-francis-council-of-science-editors-author-date-core"
203            }
204            StyleBase::TaylorAndFrancisNationalLibraryOfMedicineCore => {
205                "taylor-and-francis-national-library-of-medicine-core"
206            }
207            StyleBase::ChicagoShortenedNotesBibliographyCore => {
208                "chicago-shortened-notes-bibliography-core"
209            }
210            StyleBase::ChicagoNotes18th => "chicago-notes-18th",
211            StyleBase::ChicagoAuthorDate18th => "chicago-author-date-18th",
212            StyleBase::ChicagoShortenedNotesBibliography => "chicago-shortened-notes-bibliography",
213            StyleBase::Apa7th => "apa-7th",
214            StyleBase::ElsevierHarvard => "elsevier-harvard",
215            StyleBase::ElsevierWithTitles => "elsevier-with-titles",
216            StyleBase::ElsevierVancouver => "elsevier-vancouver",
217            StyleBase::SpringerBasicAuthorDate => "springer-basic-author-date",
218            StyleBase::SpringerVancouverBrackets => "springer-vancouver-brackets",
219            StyleBase::SpringerBasicBrackets => "springer-basic-brackets",
220            StyleBase::AmericanMedicalAssociation => "american-medical-association",
221            StyleBase::Ieee => "ieee",
222            StyleBase::TaylorAndFrancisChicagoAuthorDate => {
223                "taylor-and-francis-chicago-author-date"
224            }
225            StyleBase::TaylorAndFrancisCouncilOfScienceEditorsAuthorDate => {
226                "taylor-and-francis-council-of-science-editors-author-date"
227            }
228            StyleBase::TaylorAndFrancisNationalLibraryOfMedicine => {
229                "taylor-and-francis-national-library-of-medicine"
230            }
231            StyleBase::ModernLanguageAssociation => "modern-language-association",
232        }
233    }
234
235    /// Return all known base variants.
236    ///
237    /// Prefer this over exhaustive `match` when iterating the registry, since
238    /// [`StyleBase`] is `#[non_exhaustive]`.
239    pub fn all() -> &'static [StyleBase] {
240        &[
241            StyleBase::ElsevierHarvardCore,
242            StyleBase::ElsevierWithTitlesCore,
243            StyleBase::ElsevierVancouverCore,
244            StyleBase::SpringerBasicAuthorDateCore,
245            StyleBase::SpringerBasicBracketsCore,
246            StyleBase::SpringerVancouverBracketsCore,
247            StyleBase::TaylorAndFrancisChicagoAuthorDateCore,
248            StyleBase::TaylorAndFrancisCouncilOfScienceEditorsAuthorDateCore,
249            StyleBase::TaylorAndFrancisNationalLibraryOfMedicineCore,
250            StyleBase::ChicagoShortenedNotesBibliographyCore,
251            StyleBase::ChicagoNotes18th,
252            StyleBase::ChicagoAuthorDate18th,
253            StyleBase::ChicagoShortenedNotesBibliography,
254            StyleBase::Apa7th,
255            StyleBase::ElsevierHarvard,
256            StyleBase::ElsevierWithTitles,
257            StyleBase::ElsevierVancouver,
258            StyleBase::SpringerBasicAuthorDate,
259            StyleBase::SpringerVancouverBrackets,
260            StyleBase::SpringerBasicBrackets,
261            StyleBase::AmericanMedicalAssociation,
262            StyleBase::Ieee,
263            StyleBase::TaylorAndFrancisChicagoAuthorDate,
264            StyleBase::TaylorAndFrancisCouncilOfScienceEditorsAuthorDate,
265            StyleBase::TaylorAndFrancisNationalLibraryOfMedicine,
266            StyleBase::ModernLanguageAssociation,
267        ]
268    }
269
270    /// Internal resolver with loop protection that preserves profile errors.
271    pub(crate) fn try_resolve_with_visited(
272        &self,
273        resolver: Option<&crate::StyleResolver>,
274        visited: &mut HashSet<String>,
275    ) -> Result<Style, crate::ResolutionError> {
276        self.base()
277            .try_into_resolved_recursive_with(resolver, visited)
278    }
279}
280
281#[cfg(test)]
282#[allow(
283    clippy::unwrap_used,
284    clippy::expect_used,
285    clippy::panic,
286    clippy::indexing_slicing,
287    clippy::todo,
288    clippy::unimplemented,
289    clippy::unreachable,
290    clippy::get_unwrap,
291    reason = "Panicking is acceptable and often desired in tests."
292)]
293mod tests {
294    use super::*;
295    use crate::options::{Config, PageRangeFormat};
296    use crate::{Style, StyleInfo, TemplateVariant};
297
298    #[test]
299    fn style_base_chicago_notes_base_is_valid() {
300        let style = StyleBase::ChicagoNotes18th.base();
301        let yaml = serde_yaml::to_string(&style).expect("serialization failed");
302        let back: Style = serde_yaml::from_str(&yaml).expect("deserialization failed");
303        assert!(back.info.title.is_some(), "title should be present");
304        assert!(
305            back.citation
306                .as_ref()
307                .and_then(|citation| citation.ibid.as_ref())
308                .is_some()
309        );
310    }
311
312    #[test]
313    fn style_base_chicago_author_date_base_is_valid() {
314        let style = StyleBase::ChicagoAuthorDate18th.base();
315        assert!(style.info.title.is_some(), "title should be present");
316    }
317
318    #[test]
319    fn style_base_apa_7th_base_is_valid() {
320        let style = StyleBase::Apa7th.base();
321        assert!(style.info.title.is_some(), "title should be present");
322        assert!(
323            style.extends.is_none(),
324            "apa-7th is a Tier-1 base and must not extend anything"
325        );
326        let citation = style.citation.as_ref().expect("citation should be present");
327        assert!(
328            citation.template_ref.is_none(),
329            "APA base should carry authored citation templates"
330        );
331        assert!(
332            citation.template.is_none(),
333            "APA base should not define a top-level citation template"
334        );
335        assert!(
336            citation
337                .integral
338                .as_ref()
339                .is_some_and(|i| i.template.is_some()),
340            "APA base should define an authored integral citation template"
341        );
342        assert!(
343            citation
344                .non_integral
345                .as_ref()
346                .is_some_and(|ni| ni.template.is_some()),
347            "APA base should define an authored non-integral citation template"
348        );
349
350        let bibliography = style
351            .bibliography
352            .as_ref()
353            .expect("bibliography should be present");
354        assert!(
355            bibliography.template_ref.is_none(),
356            "APA base should carry authored bibliography templates"
357        );
358        assert!(
359            bibliography.template.is_some(),
360            "APA base should define an authored bibliography template"
361        );
362        assert!(
363            bibliography
364                .type_variants
365                .as_ref()
366                .is_some_and(|variants| !variants.is_empty()),
367            "APA base should define authored bibliography type variants"
368        );
369    }
370
371    #[test]
372    fn style_base_yaml_roundtrip() {
373        let yaml = "chicago-notes-18th";
374        let base: StyleBase = serde_yaml::from_str(yaml).expect("deserialization failed");
375        assert_eq!(base, StyleBase::ChicagoNotes18th);
376
377        let back = serde_yaml::to_string(&base).expect("serialization failed");
378        assert!(back.trim() == "chicago-notes-18th");
379    }
380
381    #[test]
382    fn top_level_null_field_clears_inherited_base_value() {
383        // A style that inherits Chicago Notes but disables ibid via a
384        // top-level citation block — the canonical authoring pattern
385        // since there is no separate variant layer.
386        let yaml = r#"
387extends: chicago-notes-18th
388citation:
389  ibid: ~
390"#;
391        let style: Style = Style::from_yaml_str(yaml).expect("style parses");
392        let resolved = style.into_resolved();
393        assert!(
394            resolved
395                .citation
396                .as_ref()
397                .expect("citation present")
398                .ibid
399                .is_none(),
400            "top-level null should clear inherited ibid"
401        );
402        assert!(
403            resolved.citation.as_ref().unwrap().template.is_some(),
404            "top-level override should preserve the inherited template"
405        );
406    }
407
408    #[test]
409    fn local_style_overrides_merge_with_base() {
410        let style = Style {
411            info: StyleInfo {
412                title: Some("Taylor & Francis Test".to_string()),
413                id: Some("tf-test".into()),
414                ..Default::default()
415            },
416            extends: Some(StyleBase::ChicagoAuthorDate18th.into()),
417            options: Some(Config {
418                page_range_format: Some(PageRangeFormat::Expanded),
419                ..Default::default()
420            }),
421            ..Default::default()
422        };
423
424        let resolved = style.into_resolved();
425        let options = resolved
426            .options
427            .expect("resolved options should be present");
428        assert_eq!(options.page_range_format, Some(PageRangeFormat::Expanded));
429        assert!(
430            options.processing.is_some(),
431            "local override should preserve inherited processing"
432        );
433        assert!(
434            resolved.citation.is_some(),
435            "local override should preserve inherited citation spec"
436        );
437    }
438
439    #[test]
440    fn style_base_resolution_materializes_template_v3_variants() {
441        let mut visited = HashSet::new();
442        let resolved = StyleBase::Ieee
443            .try_resolve_with_visited(None, &mut visited)
444            .expect("ieee base resolves");
445        let variants = resolved
446            .bibliography
447            .as_ref()
448            .and_then(|bibliography| bibliography.type_variants.as_ref())
449            .expect("ieee bibliography variants resolve");
450
451        assert!(
452            variants
453                .values()
454                .all(|variant| matches!(variant, TemplateVariant::Full(_)))
455        );
456    }
457
458    #[test]
459    fn style_base_circular_dependency_is_handled() {
460        let mut base = StyleBase::ChicagoNotes18th.base();
461        base.extends = Some(StyleBase::ChicagoNotes18th.into());
462
463        let _ = base.try_into_resolved();
464    }
465
466    #[test]
467    fn all_bases_resolve_cleanly() {
468        for base in StyleBase::all() {
469            let resolved = base.base().into_resolved();
470            assert!(
471                resolved.citation.is_some(),
472                "{} resolved citation missing",
473                base.key()
474            );
475            assert!(
476                resolved.options.is_some(),
477                "{} resolved options missing",
478                base.key()
479            );
480        }
481    }
482
483    #[test]
484    fn tier1_bases_have_no_extends_field() {
485        // Tier-1 base styles must not contain an extends: field — they ARE the root.
486        // Profile styles (Tier-2) in StyleBase may legitimately extend a base.
487        let tier1 = [
488            StyleBase::Apa7th,
489            StyleBase::ChicagoNotes18th,
490            StyleBase::ChicagoAuthorDate18th,
491            StyleBase::Ieee,
492            StyleBase::AmericanMedicalAssociation,
493            StyleBase::ModernLanguageAssociation,
494        ];
495        for base in &tier1 {
496            assert!(
497                base.base().extends.is_none(),
498                "{} is a Tier-1 base and must not have an extends: field",
499                base.key()
500            );
501        }
502    }
503
504    #[test]
505    fn turabian_pattern_disables_ibid_via_top_level_citation() {
506        // Turabian 9th ed. = Chicago Notes + ibid disabled.
507        // With no variant layer, this is expressed as a top-level citation override.
508        let yaml = r#"
509info:
510  title: "Turabian 9th"
511extends: chicago-notes-18th
512citation:
513  ibid: ~
514"#;
515        let style = Style::from_yaml_str(yaml).expect("style parses");
516        let resolved = style.into_resolved();
517        let citation = resolved.citation.expect("citation should be present");
518        assert!(citation.ibid.is_none(), "ibid should be disabled");
519        assert!(
520            citation.template.is_some(),
521            "inherited template should be preserved"
522        );
523    }
524}