Skip to main content

citum_schema_style/
template.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Template components for Citum styles.
7//!
8//! This module defines the declarative template language for Citum.
9//! Unlike CSL 1.0's procedural rendering elements, these components
10//! are simple, typed instructions that the processor interprets.
11//!
12//! ## Design Philosophy
13//!
14//! **Explicit over magic**: All rendering behavior should be expressible in the
15//! style YAML. The processor should not have hidden conditional logic based on
16//! reference types. Instead, use `overrides` to declare type-specific behavior.
17//!
18//! ## Type-Specific Overrides
19//!
20//! Components support `overrides` to customize rendering per reference type:
21//!
22//! ```yaml
23//! - variable: publisher
24//!   overrides:
25//!     article-journal:
26//!       suppress: true  # Don't show publisher for journals
27//! - number: pages
28//!   overrides:
29//!     chapter:
30//!       wrap: parentheses
31//!       label-form: short  # Show as "(pp. 1-10)" for English chapters
32//! ```
33//!
34//! This keeps all conditional logic in the style, making it testable and portable.
35
36use crate::locale::{GeneralTerm, GrammaticalGender, TermForm};
37use indexmap::IndexMap;
38#[cfg(feature = "schema")]
39use schemars::JsonSchema;
40use serde::{Deserialize, Deserializer, Serialize, Serializer};
41use std::borrow::Cow;
42use std::collections::{BTreeMap, HashMap};
43use std::hash::{Hash, Hasher};
44
45mod reference;
46pub(crate) mod resolution;
47
48pub(crate) use reference::locale_matches;
49pub use reference::{LocalizedTemplateSpec, TemplatePreset, TemplateReference};
50pub(crate) use resolution::{inherited_variant_context, resolve_style_template_variants};
51
52/// Resolve a style's local template variants in place without inherited
53/// context, materializing every diff variant as a full template.
54///
55/// Diff variants are resolved against the style's own section templates and
56/// intra-section `extends` chains. Emitters that re-parent a style (for
57/// example the migration wrapper path) use this before attaching `extends`:
58/// a diff derived against the local template would otherwise resolve against
59/// the parent's same-selector variant at render time.
60///
61/// # Errors
62///
63/// Returns a [`crate::ResolutionError`] when a variant cycle, missing
64/// parent, or non-matching diff operation is found.
65pub fn resolve_local_template_variants(
66    style: &mut crate::Style,
67) -> Result<(), crate::ResolutionError> {
68    resolution::resolve_style_template_variants(style, None)
69}
70
71/// A named template (reusable sequence of components).
72pub type Template = Vec<TemplateComponent>;
73
74/// Type-specific template variants keyed by reference-type selector.
75pub type TemplateVariants = IndexMap<TypeSelector, TemplateVariant>;
76
77/// Vertical text alignment relative to the baseline.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[cfg_attr(feature = "schema", derive(JsonSchema))]
80#[serde(rename_all = "kebab-case")]
81pub enum VerticalAlign {
82    /// Render at the baseline (default).
83    Baseline,
84    /// Render as superscript.
85    Superscript,
86    /// Render as subscript.
87    Subscript,
88}
89
90/// Rendering instructions applied to template components.
91///
92/// These fields are flattened into parent structs, so in YAML you write:
93/// ```yaml
94/// - title: primary
95///   emph: true
96///   prefix: "In "
97/// ```
98/// Rather than nesting under a `rendering:` key.
99#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
100#[cfg_attr(feature = "schema", derive(JsonSchema))]
101#[serde(rename_all = "kebab-case", default)]
102pub struct Rendering {
103    /// Text-case transform to apply to the rendered value.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub text_case: Option<crate::options::titles::TextCase>,
106    /// Render in italics/emphasis.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub emph: Option<bool>,
109    /// Render in quotes.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub quote: Option<bool>,
112    /// Render in bold/strong.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub strong: Option<bool>,
115    /// Render in small caps.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub small_caps: Option<bool>,
118    /// Vertical alignment to apply to rendered output.
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub vertical_align: Option<VerticalAlign>,
121    /// Text to prepend to the rendered value (outside any wrap).
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub prefix: Option<String>,
124    /// Text to append to the rendered value (outside any wrap).
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub suffix: Option<String>,
127    /// Wrapping punctuation and optional inner affixes (text inside the wrap).
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub wrap: Option<WrapConfig>,
130    /// If true, suppress this component entirely (render as empty string).
131    /// Useful for type-specific overrides like suppressing publisher for journals.
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub suppress: Option<bool>,
134    /// Override name initialization (e.g., ". " or "").
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub initialize_with: Option<String>,
137    /// Override name form (e.g., initials, full, family-only).
138    #[serde(skip_serializing_if = "Option::is_none", rename = "name-form")]
139    pub name_form: Option<crate::options::contributors::NameForm>,
140    /// Strip trailing periods from rendered value.
141    #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
142    pub strip_periods: Option<bool>,
143}
144
145impl Rendering {
146    /// Merge another rendering configuration into this one.
147    ///
148    /// The other rendering takes precedence, overwriting any fields that are present.
149    pub fn merge(&mut self, other: &Rendering) {
150        crate::merge_options!(
151            self,
152            other,
153            text_case,
154            emph,
155            quote,
156            strong,
157            small_caps,
158            vertical_align,
159            prefix,
160            suffix,
161            wrap,
162            suppress,
163            initialize_with,
164            name_form,
165            strip_periods,
166        );
167    }
168}
169
170/// Punctuation to wrap a component in.
171#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
172#[cfg_attr(feature = "schema", derive(JsonSchema))]
173#[serde(rename_all = "kebab-case")]
174pub enum WrapPunctuation {
175    #[default]
176    Parentheses,
177    Brackets,
178    Quotes,
179}
180
181/// Wrapping punctuation and optional inner affixes applied around a rendered value.
182///
183/// Combines the wrap punctuation with optional text that appears inside the wrap
184/// (between the wrap character and the rendered content).
185#[derive(Debug, Clone, PartialEq, Serialize)]
186#[cfg_attr(feature = "schema", derive(JsonSchema))]
187#[serde(rename_all = "kebab-case")]
188pub struct WrapConfig {
189    /// The wrapping punctuation style.
190    pub punctuation: WrapPunctuation,
191    /// Text inserted after the opening wrap character but before the content.
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub inner_prefix: Option<String>,
194    /// Text inserted after the content but before the closing wrap character.
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub inner_suffix: Option<String>,
197}
198
199impl<'de> serde::Deserialize<'de> for WrapConfig {
200    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
201        struct WrapConfigVisitor;
202
203        impl<'de> serde::de::Visitor<'de> for WrapConfigVisitor {
204            type Value = WrapConfig;
205
206            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
207                write!(
208                    f,
209                    "a wrap punctuation string or a mapping with a 'punctuation' key"
210                )
211            }
212
213            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<WrapConfig, E> {
214                let punctuation = match v {
215                    "parentheses" => WrapPunctuation::Parentheses,
216                    "brackets" => WrapPunctuation::Brackets,
217                    "quotes" => WrapPunctuation::Quotes,
218                    other => {
219                        return Err(E::unknown_variant(
220                            other,
221                            &["parentheses", "brackets", "quotes"],
222                        ));
223                    }
224                };
225                Ok(WrapConfig {
226                    punctuation,
227                    inner_prefix: None,
228                    inner_suffix: None,
229                })
230            }
231
232            fn visit_map<A: serde::de::MapAccess<'de>>(
233                self,
234                mut map: A,
235            ) -> Result<WrapConfig, A::Error> {
236                let mut punctuation: Option<WrapPunctuation> = None;
237                let mut inner_prefix: Option<String> = None;
238                let mut inner_suffix: Option<String> = None;
239
240                while let Some(key) = map.next_key::<String>()? {
241                    match key.as_str() {
242                        "punctuation" => {
243                            punctuation = Some(map.next_value()?);
244                        }
245                        "inner-prefix" => {
246                            inner_prefix = Some(map.next_value()?);
247                        }
248                        "inner-suffix" => {
249                            inner_suffix = Some(map.next_value()?);
250                        }
251                        other => {
252                            return Err(serde::de::Error::unknown_field(
253                                other,
254                                &["punctuation", "inner-prefix", "inner-suffix"],
255                            ));
256                        }
257                    }
258                }
259
260                let punctuation =
261                    punctuation.ok_or_else(|| serde::de::Error::missing_field("punctuation"))?;
262                Ok(WrapConfig {
263                    punctuation,
264                    inner_prefix,
265                    inner_suffix,
266                })
267            }
268        }
269
270        deserializer.deserialize_any(WrapConfigVisitor)
271    }
272}
273
274impl From<WrapPunctuation> for WrapConfig {
275    fn from(punctuation: WrapPunctuation) -> Self {
276        WrapConfig {
277            punctuation,
278            inner_prefix: None,
279            inner_suffix: None,
280        }
281    }
282}
283
284/// Canonical reference type names recognized by the Citum engine.
285///
286/// Used by [`validate_type_name`] to detect likely typos.
287pub const VALID_TYPE_NAMES: &[&str] = &[
288    "book",
289    "manual",
290    "report",
291    "thesis",
292    "webpage",
293    "map",
294    "post",
295    "interview",
296    "manuscript",
297    "personal-communication",
298    "document",
299    "chapter",
300    "entry-dictionary",
301    "paper-conference",
302    "article-journal",
303    "article-magazine",
304    "article-newspaper",
305    "broadcast",
306    "motion-picture",
307    "collection",
308    "legal-case",
309    "statute",
310    "treaty",
311    "hearing",
312    "regulation",
313    "brief",
314    "classic",
315    "patent",
316    "dataset",
317    "standard",
318    "software",
319    // Special keywords
320    "all",
321    "default",
322];
323
324/// Returns `true` if `s` is a recognized reference type name.
325///
326/// Normalizes underscores to hyphens before comparing, so both
327/// `"article_journal"` and `"article-journal"` are accepted.
328/// Returns `false` for unrecognized names (likely typos).
329pub fn validate_type_name(s: &str) -> bool {
330    let normalized = s.replace('_', "-");
331    VALID_TYPE_NAMES.iter().any(|&known| known == normalized)
332}
333
334/// Selector for reference types in overrides.
335/// Can be a single type string or a list of types.
336#[derive(Debug, Clone, PartialEq, Eq, Hash)]
337#[cfg_attr(feature = "schema", derive(JsonSchema))]
338pub enum TypeSelector {
339    Single(String),
340    Multiple(Vec<String>),
341}
342
343impl Serialize for TypeSelector {
344    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
345    where
346        S: serde::Serializer,
347    {
348        serializer.serialize_str(&self.to_string())
349    }
350}
351
352impl<'de> Deserialize<'de> for TypeSelector {
353    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
354    where
355        D: serde::Deserializer<'de>,
356    {
357        struct Visitor;
358        impl<'de> serde::de::Visitor<'de> for Visitor {
359            type Value = TypeSelector;
360
361            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
362                formatter.write_str("a string or a sequence of strings")
363            }
364
365            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
366            where
367                E: serde::de::Error,
368            {
369                v.parse().map_err(E::custom)
370            }
371
372            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
373            where
374                A: serde::de::SeqAccess<'de>,
375            {
376                let mut types = Vec::new();
377                while let Some(t) = seq.next_element::<String>()? {
378                    types.push(t);
379                }
380                if types.len() == 1 {
381                    Ok(TypeSelector::Single(types.remove(0)))
382                } else {
383                    Ok(TypeSelector::Multiple(types))
384                }
385            }
386        }
387        deserializer.deserialize_any(Visitor)
388    }
389}
390
391impl std::fmt::Display for TypeSelector {
392    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
393        match self {
394            TypeSelector::Single(s) => write!(f, "{s}"),
395            TypeSelector::Multiple(types) => write!(f, "{}", types.join(",")),
396        }
397    }
398}
399
400impl std::str::FromStr for TypeSelector {
401    type Err = std::convert::Infallible;
402
403    fn from_str(s: &str) -> Result<Self, Self::Err> {
404        if s.contains(',') {
405            Ok(TypeSelector::Multiple(
406                s.split(',').map(|t| t.trim().to_string()).collect(),
407            ))
408        } else {
409            Ok(TypeSelector::Single(s.to_string()))
410        }
411    }
412}
413
414impl TypeSelector {
415    /// Check whether this selector matches a reference type.
416    ///
417    /// Type names are compared after normalizing underscores to hyphens, so
418    /// "legal_case" and "legal-case" are treated as equivalent (matching both
419    /// CSL 1.0 underscore convention and Citum hyphen convention).
420    ///
421    /// The special keyword "all" always matches any reference type.
422    pub fn matches(&self, ref_type: &str) -> bool {
423        let normalized_ref = ref_type.replace('_', "-");
424        let base_ref = normalized_ref
425            .split_once('+')
426            .map(|(base, _)| base)
427            .unwrap_or(&normalized_ref);
428        let eq = |s: &str| -> bool {
429            s == ref_type
430                || s.replace('_', "-") == normalized_ref
431                || s.replace('_', "-") == base_ref
432                || s == "all"
433                || (s == "default" && ref_type == "default")
434        };
435        match self {
436            TypeSelector::Single(s) => eq(s),
437            TypeSelector::Multiple(types) => types.iter().any(|t| eq(t)),
438        }
439    }
440
441    /// Returns any type names in this selector that are not in [`VALID_TYPE_NAMES`].
442    ///
443    /// An empty vec means all names are valid. Callers should emit a
444    /// [`crate::SchemaWarning`] for each returned name.
445    pub fn unknown_type_names(&self) -> Vec<&str> {
446        match self {
447            TypeSelector::Single(s) => {
448                if validate_type_name(s) {
449                    vec![]
450                } else {
451                    vec![s.as_str()]
452                }
453            }
454            TypeSelector::Multiple(types) => types
455                .iter()
456                .filter(|s| !validate_type_name(s))
457                .map(|s| s.as_str())
458                .collect(),
459        }
460    }
461}
462
463/// A template component - the building blocks of citation/bibliography templates.
464///
465/// Each variant handles a specific data type with appropriate formatting options.
466#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
467#[cfg_attr(feature = "schema", derive(JsonSchema))]
468#[serde(untagged)]
469#[non_exhaustive]
470pub enum TemplateComponent {
471    Contributor(TemplateContributor),
472    Date(TemplateDate),
473    Title(TemplateTitle),
474    Number(TemplateNumber),
475    Variable(TemplateVariable),
476    Message(TemplateMessage),
477    Group(TemplateGroup),
478    Term(TemplateTerm),
479    TypeLabel(TemplateTypeLabel),
480}
481
482impl Default for TemplateComponent {
483    fn default() -> Self {
484        TemplateComponent::Variable(TemplateVariable::default())
485    }
486}
487
488impl TemplateComponent {
489    /// Return the rendering options for this component.
490    ///
491    /// Every template component has rendering options like emphasis, wrapping, and prefixes.
492    pub fn rendering(&self) -> &Rendering {
493        crate::dispatch_component!(self, |inner| &inner.rendering)
494    }
495
496    /// Return the mutable rendering options for this component.
497    ///
498    /// Provides mutable access to rendering fields (prefix, suffix, etc.)
499    /// that are present on all template component variants.
500    pub fn rendering_mut(&mut self) -> &mut Rendering {
501        crate::dispatch_component!(self, |inner| &mut inner.rendering)
502    }
503}
504
505/// Type-specific template override, either as a complete legacy template or a V3 diff.
506#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
507#[cfg_attr(feature = "schema", derive(JsonSchema))]
508#[serde(untagged)]
509pub enum TemplateVariant {
510    /// Complete replacement template used by Template V1/V2 styles.
511    Full(Vec<TemplateComponent>),
512    /// Structural diff applied to a parent template during style resolution.
513    Diff(TemplateVariantDiff),
514}
515
516impl TemplateVariant {
517    /// Return this variant as a concrete template if it has already been resolved.
518    #[must_use]
519    pub fn as_template(&self) -> Option<&[TemplateComponent]> {
520        match self {
521            Self::Full(template) => Some(template.as_slice()),
522            Self::Diff(_) => None,
523        }
524    }
525
526    /// Return this variant as a mutable concrete template if it has already been resolved.
527    pub fn as_template_mut(&mut self) -> Option<&mut Vec<TemplateComponent>> {
528        match self {
529            Self::Full(template) => Some(template),
530            Self::Diff(_) => None,
531        }
532    }
533
534    /// Convert this variant into its concrete template if it has already been resolved.
535    #[must_use]
536    pub fn into_template(self) -> Option<Vec<TemplateComponent>> {
537        match self {
538            Self::Full(template) => Some(template),
539            Self::Diff(_) => None,
540        }
541    }
542}
543
544impl From<Vec<TemplateComponent>> for TemplateVariant {
545    fn from(template: Vec<TemplateComponent>) -> Self {
546        Self::Full(template)
547    }
548}
549
550/// Structural diff that derives a type-specific template from a parent template.
551#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
552#[cfg_attr(feature = "schema", derive(JsonSchema))]
553#[serde(rename_all = "kebab-case", deny_unknown_fields)]
554pub struct TemplateVariantDiff {
555    /// Optional parent type variant selector within the same section.
556    #[serde(skip_serializing_if = "Option::is_none")]
557    pub extends: Option<TypeSelector>,
558    /// Rendering-only modifications applied in authored order.
559    #[serde(skip_serializing_if = "Vec::is_empty", default)]
560    pub modify: Vec<TemplateModifyOperation>,
561    /// Component removals applied in authored order.
562    #[serde(skip_serializing_if = "Vec::is_empty", default)]
563    pub remove: Vec<TemplateRemoveOperation>,
564    /// Component additions applied in authored order.
565    #[serde(skip_serializing_if = "Vec::is_empty", default)]
566    pub add: Vec<TemplateAddOperation>,
567}
568
569/// Partial component selector used to locate anchors in a template.
570#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
571#[cfg_attr(feature = "schema", derive(JsonSchema))]
572#[serde(transparent)]
573pub struct TemplateComponentSelector {
574    /// Component fields that must be present with equal values on the target component.
575    pub fields: BTreeMap<String, serde_json::Value>,
576}
577
578impl TemplateComponentSelector {
579    /// Returns `true` when this selector has no fields.
580    #[must_use]
581    pub fn is_empty(&self) -> bool {
582        self.fields.is_empty()
583    }
584
585    /// Returns `true` when every selector field is present with the same value.
586    #[must_use]
587    pub fn matches(&self, component: &TemplateComponent) -> bool {
588        let Ok(serde_json::Value::Object(component_fields)) = serde_json::to_value(component)
589        else {
590            return false;
591        };
592
593        self.fields.iter().all(|(key, expected)| {
594            component_fields
595                .get(key)
596                .is_some_and(|actual| selector_value_matches(expected, actual))
597        })
598    }
599}
600
601fn selector_value_matches(expected: &serde_json::Value, actual: &serde_json::Value) -> bool {
602    match (expected, actual) {
603        (serde_json::Value::Object(expected_fields), serde_json::Value::Object(actual_fields)) => {
604            expected_fields.iter().all(|(key, expected_value)| {
605                actual_fields.get(key).is_some_and(|actual_value| {
606                    selector_value_matches(expected_value, actual_value)
607                })
608            })
609        }
610        (serde_json::Value::Array(expected_items), serde_json::Value::Array(actual_items)) => {
611            expected_items.len() == actual_items.len()
612                && expected_items.iter().zip(actual_items.iter()).all(
613                    |(expected_item, actual_item)| {
614                        selector_value_matches(expected_item, actual_item)
615                    },
616                )
617        }
618        _ => expected == actual,
619    }
620}
621
622/// Rendering-only modification for the component matched by `match`.
623#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
624#[cfg_attr(feature = "schema", derive(JsonSchema))]
625#[serde(rename_all = "kebab-case", deny_unknown_fields)]
626pub struct TemplateModifyOperation {
627    /// Selector identifying exactly one component to modify.
628    #[serde(rename = "match")]
629    pub match_selector: TemplateComponentSelector,
630    /// Override the localized number label form when modifying number components.
631    #[serde(skip_serializing_if = "Option::is_none")]
632    pub label_form: Option<LabelForm>,
633    /// Rendering fields to merge onto the matched component.
634    #[serde(flatten, default)]
635    pub rendering: Rendering,
636}
637
638/// Removal operation for the component matched by `match`.
639#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
640#[cfg_attr(feature = "schema", derive(JsonSchema))]
641#[serde(rename_all = "kebab-case", deny_unknown_fields)]
642pub struct TemplateRemoveOperation {
643    /// Selector identifying exactly one component to remove.
644    #[serde(rename = "match")]
645    pub match_selector: TemplateComponentSelector,
646}
647
648/// Addition operation that inserts a component before or after an anchor.
649#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
650#[cfg_attr(feature = "schema", derive(JsonSchema))]
651#[serde(rename_all = "kebab-case", deny_unknown_fields)]
652pub struct TemplateAddOperation {
653    /// Anchor selector before which the component should be inserted.
654    #[serde(skip_serializing_if = "Option::is_none")]
655    pub before: Option<TemplateComponentSelector>,
656    /// Anchor selector after which the component should be inserted.
657    #[serde(skip_serializing_if = "Option::is_none")]
658    pub after: Option<TemplateComponentSelector>,
659    /// Component to insert.
660    pub component: TemplateComponent,
661}
662
663/// Configuration for role labels (e.g., "eds.", "trans.").
664#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
665#[cfg_attr(feature = "schema", derive(JsonSchema))]
666#[serde(rename_all = "kebab-case")]
667pub struct RoleLabel {
668    /// Locale term key for the role (e.g., "editor", "translator").
669    pub term: String,
670    /// Term form: short ("eds.") or long ("editors").
671    #[serde(default)]
672    pub form: RoleLabelForm,
673    /// Where to place the label relative to names.
674    #[serde(default)]
675    pub placement: LabelPlacement,
676    /// Optional case transform applied to the resolved label term, e.g.
677    /// `capitalize-first` renders "Eds." from the locale's "eds." (as IEEE
678    /// requires). When unset the term is rendered as the locale stores it.
679    #[serde(default, skip_serializing_if = "Option::is_none")]
680    pub text_case: Option<crate::options::titles::TextCase>,
681    /// Optional punctuation wrapped around the resolved label term.
682    ///
683    /// The wrap is applied before the label's outer `prefix` and `suffix`.
684    #[serde(default, skip_serializing_if = "Option::is_none")]
685    pub wrap: Option<Box<WrapConfig>>,
686    /// Optional affix rendered before the label term, overriding the
687    /// placement-derived default (a space for a wrapped suffix label, `", "`
688    /// for an unwrapped suffix label, and empty for prefix placement). Mirrors
689    /// CSL 1.0 `cs:label` `prefix` (e.g. `" ("` for elsevier's `" (Eds.)"`).
690    #[serde(default, skip_serializing_if = "Option::is_none")]
691    pub prefix: Option<String>,
692    /// Optional affix rendered after the label term, overriding the
693    /// placement-derived default (empty for suffix placement, `" "` for
694    /// prefix placement). Mirrors CSL 1.0 `cs:label` `suffix` (e.g. `")"`).
695    #[serde(default, skip_serializing_if = "Option::is_none")]
696    pub suffix: Option<String>,
697}
698
699/// Term form for role labels.
700#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
701#[cfg_attr(feature = "schema", derive(JsonSchema))]
702#[serde(rename_all = "kebab-case")]
703pub enum RoleLabelForm {
704    #[default]
705    Short,
706    Long,
707}
708
709/// Label placement relative to contributor names.
710#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
711#[cfg_attr(feature = "schema", derive(JsonSchema))]
712#[serde(rename_all = "kebab-case")]
713pub enum LabelPlacement {
714    Prefix,
715    #[default]
716    Suffix,
717}
718
719/// One contributor role or an ordered list of roles rendered as one name list.
720#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
721#[cfg_attr(feature = "schema", derive(JsonSchema))]
722#[serde(untagged)]
723pub enum ContributorRoles {
724    /// A conventional single-role contributor component.
725    Single(ContributorRole),
726    /// Two or more contributor roles rendered as a merged list.
727    Multiple(#[cfg_attr(feature = "schema", schemars(length(min = 2)))] Vec<ContributorRole>),
728}
729
730impl Default for ContributorRoles {
731    fn default() -> Self {
732        Self::Single(ContributorRole::Author)
733    }
734}
735
736impl ContributorRoles {
737    /// Return all declared roles in authoring order.
738    #[must_use]
739    pub fn as_slice(&self) -> &[ContributorRole] {
740        match self {
741            Self::Single(role) => std::slice::from_ref(role),
742            Self::Multiple(roles) => roles,
743        }
744    }
745
746    /// Return the role when this is the scalar form.
747    #[must_use]
748    pub fn as_single(&self) -> Option<&ContributorRole> {
749        match self {
750            Self::Single(role) => Some(role),
751            Self::Multiple(_) => None,
752        }
753    }
754
755    /// Return whether this is the list form.
756    #[must_use]
757    pub fn is_multiple(&self) -> bool {
758        matches!(self, Self::Multiple(_))
759    }
760
761    /// Return whether the declaration contains `role`.
762    #[must_use]
763    pub fn contains(&self, role: &ContributorRole) -> bool {
764        self.as_slice().contains(role)
765    }
766}
767
768impl From<ContributorRole> for ContributorRoles {
769    fn from(role: ContributorRole) -> Self {
770        Self::Single(role)
771    }
772}
773
774impl From<Vec<ContributorRole>> for ContributorRoles {
775    fn from(roles: Vec<ContributorRole>) -> Self {
776        Self::Multiple(roles)
777    }
778}
779
780impl PartialEq<ContributorRole> for ContributorRoles {
781    fn eq(&self, other: &ContributorRole) -> bool {
782        self.as_single() == Some(other)
783    }
784}
785
786/// Ordering policy for a merged contributor list.
787#[derive(Debug, Default, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
788#[cfg_attr(feature = "schema", derive(JsonSchema))]
789#[serde(rename_all = "kebab-case")]
790pub enum ContributorMergeOrder {
791    /// Preserve the unified reference contributor order.
792    #[default]
793    Document,
794    /// Group entries by the component's declared role order.
795    Role,
796}
797
798/// Role-label placement mode for merged contributor entries.
799#[derive(Debug, Default, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
800#[cfg_attr(feature = "schema", derive(JsonSchema))]
801#[serde(rename_all = "kebab-case")]
802pub enum ContributorLabelMode {
803    /// Attach a singular role label to every rendered person.
804    #[default]
805    Individual,
806    /// Attach one singular or plural label to each contiguous role run.
807    Collective,
808    /// Render names without role labels.
809    None,
810}
811
812/// Per-role overrides within a merged contributor list.
813#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
814#[cfg_attr(feature = "schema", derive(JsonSchema))]
815#[serde(rename_all = "kebab-case", deny_unknown_fields)]
816pub struct ContributorMergeRole {
817    /// Override the merged list's default label mode for this role.
818    #[serde(skip_serializing_if = "Option::is_none")]
819    pub labels: Option<ContributorLabelMode>,
820    /// Override label term, form, placement, case, and affixes for this role.
821    #[serde(skip_serializing_if = "Option::is_none")]
822    pub label: Option<RoleLabel>,
823}
824
825/// Configuration for rendering multiple contributor roles as one name list.
826#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
827#[cfg_attr(feature = "schema", derive(JsonSchema))]
828#[serde(rename_all = "kebab-case", deny_unknown_fields)]
829pub struct ContributorMerge {
830    /// Effective ordering of entries in the merged list.
831    #[serde(default)]
832    pub order: ContributorMergeOrder,
833    /// Default role-label mode for entries in the merged list.
834    #[serde(default)]
835    pub labels: ContributorLabelMode,
836    /// Optional per-role label overrides.
837    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
838    pub roles: HashMap<ContributorRole, ContributorMergeRole>,
839    /// Whether identical people in different roles render as one entry.
840    #[serde(default = "default_combine_same_person")]
841    pub combine_same_person: bool,
842    /// Verbatim connector used when composing a missing combined-role term.
843    #[serde(skip_serializing_if = "Option::is_none")]
844    pub role_conjunction: Option<String>,
845}
846
847fn default_combine_same_person() -> bool {
848    true
849}
850
851impl Default for ContributorMerge {
852    fn default() -> Self {
853        Self {
854            order: ContributorMergeOrder::Document,
855            labels: ContributorLabelMode::Individual,
856            roles: HashMap::new(),
857            combine_same_person: true,
858            role_conjunction: None,
859        }
860    }
861}
862
863/// A contributor component for rendering names.
864#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
865#[cfg_attr(feature = "schema", derive(JsonSchema))]
866#[serde(rename_all = "kebab-case", deny_unknown_fields)]
867pub struct TemplateContributor {
868    /// Which contributor role or ordered role list to render.
869    pub contributor: ContributorRoles,
870    /// How to display the contributor (long names, short, with label, etc.).
871    pub form: ContributorForm,
872    /// Optional role label configuration (e.g., "eds." for editors).
873    #[serde(skip_serializing_if = "Option::is_none")]
874    pub label: Option<RoleLabel>,
875    /// Configuration used when `contributor` is an ordered role list.
876    #[serde(skip_serializing_if = "Option::is_none")]
877    pub merge: Option<ContributorMerge>,
878    /// Override the global name order for this specific component.
879    /// Use to show editors as "Given Family" even when global setting is "Family, Given".
880    #[serde(skip_serializing_if = "Option::is_none")]
881    pub name_order: Option<NameOrder>,
882    /// Override the name form (e.g., initials, full, family-only) for this specific component.
883    #[serde(skip_serializing_if = "Option::is_none", rename = "name-form")]
884    pub name_form: Option<crate::options::contributors::NameForm>,
885    /// Custom delimiter between names (overrides global setting).
886    #[serde(skip_serializing_if = "Option::is_none")]
887    pub delimiter: Option<String>,
888    /// Delimiter between family and given name when inverted (overrides global setting).
889    #[serde(skip_serializing_if = "Option::is_none")]
890    pub sort_separator: Option<String>,
891    /// Shorten the list of names (et al. configuration).
892    #[serde(skip_serializing_if = "Option::is_none")]
893    pub shorten: Option<crate::options::ShortenListOptions>,
894    /// Override the conjunction between the last two names.
895    /// Use `none` for bibliography when citation uses `text` or `symbol`.
896    #[serde(skip_serializing_if = "Option::is_none")]
897    pub and: Option<crate::options::AndOptions>,
898    #[serde(flatten, default)]
899    pub rendering: Rendering,
900    /// Structured link options (DOI, URL).
901    #[serde(skip_serializing_if = "Option::is_none")]
902    pub links: Option<crate::options::LinksConfig>,
903    /// Explicit grammatical gender override for role-label agreement.
904    #[serde(skip_serializing_if = "Option::is_none")]
905    pub gender: Option<GrammaticalGender>,
906
907    /// Custom user-defined fields for extensions.
908    #[serde(skip_serializing_if = "Option::is_none")]
909    pub custom: Option<HashMap<String, serde_json::Value>>,
910}
911
912/// Name display order.
913#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
914#[cfg_attr(feature = "schema", derive(JsonSchema))]
915#[serde(rename_all = "kebab-case")]
916pub enum NameOrder {
917    /// Display as "Given Family" (e.g., "John Smith").
918    GivenFirst,
919    /// Display as "Family, Given" (e.g., "Smith, John").
920    #[default]
921    FamilyFirst,
922    /// First contributor inverted ("Family, Given"); subsequent contributors given-first.
923    FamilyFirstOnly,
924    /// Every contributor except the last inverted ("Family, Given"); the last
925    /// contributor rendered given-first. "Last" is the last name of the full
926    /// contributor list; under et-al truncation that name may be elided, in
927    /// which case all rendered names invert.
928    FamilyFirstExceptLast,
929}
930
931/// How to render contributor names.
932#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
933#[cfg_attr(feature = "schema", derive(JsonSchema))]
934#[serde(rename_all = "kebab-case")]
935pub enum ContributorForm {
936    #[default]
937    Long,
938    Short,
939    FamilyOnly,
940    Verb,
941    VerbShort,
942}
943
944crate::str_enum! {
945    /// Contributor roles.
946    #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
947    pub enum ContributorRole {
948        #[default] Author = "author",
949        Chair = "chair",
950        Editor = "editor",
951        Translator = "translator",
952        Director = "director",
953        Publisher = "publisher",
954        Recipient = "recipient",
955        Interviewer = "interviewer",
956        Interviewee = "interviewee",
957        Guest = "guest",
958        Performer = "performer",
959        Inventor = "inventor",
960        Counsel = "counsel",
961        Composer = "composer",
962        Writer = "writer",
963        Producer = "producer",
964        CollectionEditor = "collection-editor",
965        ContainerAuthor = "container-author",
966        EditorialDirector = "editorial-director",
967        TextualEditor = "textual-editor",
968        Illustrator = "illustrator",
969        OriginalAuthor = "original-author",
970        ReviewedAuthor = "reviewed-author"
971    }
972}
973
974/// A date component for rendering dates.
975#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
976#[cfg_attr(feature = "schema", derive(JsonSchema))]
977#[serde(rename_all = "kebab-case", deny_unknown_fields)]
978pub struct TemplateDate {
979    pub date: DateVariable,
980    pub form: DateForm,
981    /// Fallback components if the primary date is missing.
982    #[serde(skip_serializing_if = "Option::is_none")]
983    pub fallback: Option<Vec<TemplateComponent>>,
984    #[serde(flatten, default)]
985    pub rendering: Rendering,
986    /// Structured link options (DOI, URL).
987    #[serde(skip_serializing_if = "Option::is_none")]
988    pub links: Option<crate::options::LinksConfig>,
989
990    /// Custom user-defined fields for extensions.
991    #[serde(skip_serializing_if = "Option::is_none")]
992    pub custom: Option<HashMap<String, serde_json::Value>>,
993}
994
995/// Date variables.
996#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
997#[cfg_attr(feature = "schema", derive(JsonSchema))]
998#[serde(rename_all = "kebab-case")]
999pub enum DateVariable {
1000    #[default]
1001    Issued,
1002    Accessed,
1003    OriginalPublished,
1004    Submitted,
1005    EventDate,
1006}
1007
1008crate::str_enum! {
1009    /// Date rendering forms.
1010    #[derive(Debug, Default, Clone, PartialEq)]
1011    pub enum DateForm {
1012        #[default]
1013        Year = "year",
1014        YearMonth = "year-month",
1015        /// Month name only, no year or day: "June" (e.g. magazines whose year
1016        /// is already supplied by the author-date position).
1017        Month = "month",
1018        Full = "full",
1019        MonthDay = "month-day",
1020        YearMonthDay = "year-month-day",
1021        DayMonthAbbrYear = "day-month-abbr-year",
1022        /// Abbreviated month + day + year in US order: "Jan 15, 2024".
1023        MonthAbbrDayYear = "month-abbr-day-year"
1024    }
1025}
1026
1027/// A title component.
1028#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1029#[cfg_attr(feature = "schema", derive(JsonSchema))]
1030#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1031pub struct TemplateTitle {
1032    pub title: TitleType,
1033    #[serde(skip_serializing_if = "Option::is_none")]
1034    pub form: Option<TitleForm>,
1035    /// When true, suppress this title component unless the reference needs
1036    /// disambiguation (i.e. multiple works by the same author appear in the
1037    /// document). Used by author-class styles (e.g. MLA) where the title
1038    /// appears in citations only to resolve same-author ambiguity.
1039    #[serde(skip_serializing_if = "Option::is_none")]
1040    pub disambiguate_only: Option<bool>,
1041    #[serde(flatten, default)]
1042    pub rendering: Rendering,
1043    /// Structured link options (DOI, URL).
1044    #[serde(skip_serializing_if = "Option::is_none")]
1045    pub links: Option<crate::options::LinksConfig>,
1046
1047    /// Custom user-defined fields for extensions.
1048    #[serde(skip_serializing_if = "Option::is_none")]
1049    pub custom: Option<HashMap<String, serde_json::Value>>,
1050}
1051
1052/// Types of titles.
1053#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1054#[cfg_attr(feature = "schema", derive(JsonSchema))]
1055#[serde(rename_all = "kebab-case")]
1056#[non_exhaustive]
1057pub enum TitleType {
1058    /// The primary title of the cited work.
1059    #[default]
1060    Primary,
1061    /// Title of the parent work containing the cited work.
1062    ContainerTitle,
1063    /// Title of a book/monograph containing the cited work.
1064    ParentMonograph,
1065    /// Title of a periodical/serial containing the cited work.
1066    ParentSerial,
1067    /// Title of a series or collection containing the cited work.
1068    CollectionTitle,
1069    /// Title of the work's original publication (e.g. a translation's source-language title).
1070    Original,
1071}
1072
1073/// Title rendering forms.
1074#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1075#[cfg_attr(feature = "schema", derive(JsonSchema))]
1076#[serde(rename_all = "kebab-case")]
1077pub enum TitleForm {
1078    Short,
1079    #[default]
1080    Long,
1081}
1082
1083/// A number component (volume, issue, pages, etc.).
1084#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1085#[cfg_attr(feature = "schema", derive(JsonSchema))]
1086#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1087pub struct TemplateNumber {
1088    pub number: NumberVariable,
1089    #[serde(skip_serializing_if = "Option::is_none")]
1090    pub form: Option<NumberForm>,
1091    #[serde(skip_serializing_if = "Option::is_none")]
1092    pub label_form: Option<LabelForm>,
1093    /// When `true`, show this pages component even when a locator is present in a note-style citation.
1094    /// By default, pages are suppressed in note-style citations when a locator is present.
1095    #[serde(skip_serializing_if = "Option::is_none")]
1096    pub show_with_locator: Option<bool>,
1097    #[serde(flatten)]
1098    pub rendering: Rendering,
1099    /// Structured link options (DOI, URL).
1100    #[serde(skip_serializing_if = "Option::is_none")]
1101    pub links: Option<crate::options::LinksConfig>,
1102    /// Explicit grammatical gender override for number/ordinal agreement.
1103    #[serde(skip_serializing_if = "Option::is_none")]
1104    pub gender: Option<GrammaticalGender>,
1105
1106    /// Custom user-defined fields for extensions.
1107    #[serde(skip_serializing_if = "Option::is_none")]
1108    pub custom: Option<HashMap<String, serde_json::Value>>,
1109}
1110
1111/// Number variables.
1112///
1113/// Use `number:` when the value is treated as a number by the style:
1114/// numeric labels, numeric-specific formatting, ordinals, roman numerals, or
1115/// locator-aware punctuation. Use `variable:` instead when the field should be
1116/// passed through as plain text without number formatting semantics.
1117#[derive(Debug, Default, Clone)]
1118#[non_exhaustive]
1119pub enum NumberVariable {
1120    #[default]
1121    Volume,
1122    Issue,
1123    Pages,
1124    Edition,
1125    ChapterNumber,
1126    CollectionNumber,
1127    NumberOfPages,
1128    NumberOfVolumes,
1129    CitationNumber,
1130    /// First-occurrence note number for the cited reference (note styles only).
1131    /// Populated from the document processor; omitted (not rendered) when the
1132    /// citation is not in a subsequent position or no first-note number is available.
1133    FirstReferenceNoteNumber,
1134    CitationLabel,
1135    Number,
1136    DocketNumber,
1137    PatentNumber,
1138    StandardNumber,
1139    ReportNumber,
1140    PartNumber,
1141    SupplementNumber,
1142    PrintingNumber,
1143    /// A custom numbering variable rendered from an arbitrary numbering kind.
1144    Custom(String),
1145}
1146
1147impl NumberVariable {
1148    /// Return the canonical kebab-case key for this numeric variable.
1149    #[must_use]
1150    pub fn as_key(&self) -> Cow<'_, str> {
1151        match self {
1152            Self::Volume => Cow::Borrowed("volume"),
1153            Self::Issue => Cow::Borrowed("issue"),
1154            Self::Pages => Cow::Borrowed("pages"),
1155            Self::Edition => Cow::Borrowed("edition"),
1156            Self::ChapterNumber => Cow::Borrowed("chapter-number"),
1157            Self::CollectionNumber => Cow::Borrowed("collection-number"),
1158            Self::NumberOfPages => Cow::Borrowed("number-of-pages"),
1159            Self::NumberOfVolumes => Cow::Borrowed("number-of-volumes"),
1160            Self::CitationNumber => Cow::Borrowed("citation-number"),
1161            Self::FirstReferenceNoteNumber => Cow::Borrowed("first-reference-note-number"),
1162            Self::CitationLabel => Cow::Borrowed("citation-label"),
1163            Self::Number => Cow::Borrowed("number"),
1164            Self::DocketNumber => Cow::Borrowed("docket-number"),
1165            Self::PatentNumber => Cow::Borrowed("patent-number"),
1166            Self::StandardNumber => Cow::Borrowed("standard-number"),
1167            Self::ReportNumber => Cow::Borrowed("report-number"),
1168            Self::PartNumber => Cow::Borrowed("part-number"),
1169            Self::SupplementNumber => Cow::Borrowed("supplement-number"),
1170            Self::PrintingNumber => Cow::Borrowed("printing-number"),
1171            Self::Custom(value) => normalize_kind_key(value)
1172                .map(Cow::Owned)
1173                .unwrap_or_else(|| Cow::Borrowed(value.as_str())),
1174        }
1175    }
1176
1177    fn from_key(value: &str) -> Result<Self, String> {
1178        let canonical = normalize_kind_key(value)
1179            .ok_or_else(|| "number variable must not be empty".to_string())?;
1180        Ok(match canonical.as_str() {
1181            "volume" => Self::Volume,
1182            "issue" => Self::Issue,
1183            "pages" => Self::Pages,
1184            "edition" => Self::Edition,
1185            "chapter-number" => Self::ChapterNumber,
1186            "collection-number" => Self::CollectionNumber,
1187            "number-of-pages" => Self::NumberOfPages,
1188            "number-of-volumes" => Self::NumberOfVolumes,
1189            "citation-number" => Self::CitationNumber,
1190            "first-reference-note-number" => Self::FirstReferenceNoteNumber,
1191            "citation-label" => Self::CitationLabel,
1192            "number" => Self::Number,
1193            "docket-number" => Self::DocketNumber,
1194            "patent-number" => Self::PatentNumber,
1195            "standard-number" => Self::StandardNumber,
1196            "report-number" => Self::ReportNumber,
1197            "part-number" => Self::PartNumber,
1198            "supplement-number" => Self::SupplementNumber,
1199            "printing-number" => Self::PrintingNumber,
1200            _ => Self::Custom(canonical),
1201        })
1202    }
1203}
1204
1205impl PartialEq for NumberVariable {
1206    fn eq(&self, other: &Self) -> bool {
1207        self.as_key().as_ref() == other.as_key().as_ref()
1208    }
1209}
1210
1211impl Eq for NumberVariable {}
1212
1213impl Hash for NumberVariable {
1214    fn hash<H: Hasher>(&self, state: &mut H) {
1215        self.as_key().as_ref().hash(state);
1216    }
1217}
1218
1219impl Serialize for NumberVariable {
1220    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1221    where
1222        S: Serializer,
1223    {
1224        serializer.serialize_str(self.as_key().as_ref())
1225    }
1226}
1227
1228impl<'de> Deserialize<'de> for NumberVariable {
1229    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1230    where
1231        D: Deserializer<'de>,
1232    {
1233        let value = String::deserialize(deserializer)?;
1234        Self::from_key(&value).map_err(serde::de::Error::custom)
1235    }
1236}
1237
1238#[cfg(feature = "schema")]
1239impl JsonSchema for NumberVariable {
1240    fn schema_name() -> std::borrow::Cow<'static, str> {
1241        "NumberVariable".into()
1242    }
1243
1244    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1245        schemars::json_schema!({
1246            "type": "string",
1247            "description": "Known number variable keyword or custom kebab-case identifier."
1248        })
1249    }
1250}
1251
1252/// Number rendering forms.
1253#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1254#[cfg_attr(feature = "schema", derive(JsonSchema))]
1255#[serde(rename_all = "lowercase")]
1256pub enum NumberForm {
1257    #[default]
1258    Numeric,
1259    Ordinal,
1260    Roman,
1261}
1262
1263fn normalize_kind_key(value: &str) -> Option<String> {
1264    let mut normalized = String::new();
1265    let mut pending_dash = false;
1266
1267    for ch in value.trim().chars() {
1268        if ch.is_ascii_alphanumeric() {
1269            if pending_dash && !normalized.is_empty() {
1270                normalized.push('-');
1271            }
1272            normalized.push(ch.to_ascii_lowercase());
1273            pending_dash = false;
1274        } else if !normalized.is_empty() {
1275            pending_dash = true;
1276        }
1277    }
1278
1279    if normalized.is_empty() {
1280        None
1281    } else {
1282        Some(normalized)
1283    }
1284}
1285
1286/// Label rendering forms.
1287#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1288#[cfg_attr(feature = "schema", derive(JsonSchema))]
1289#[serde(rename_all = "kebab-case")]
1290pub enum LabelForm {
1291    Long,
1292    #[default]
1293    Short,
1294    Symbol,
1295}
1296
1297/// A simple variable component (DOI, ISBN, URL, etc.).
1298#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1299#[cfg_attr(feature = "schema", derive(JsonSchema))]
1300#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1301pub struct TemplateVariable {
1302    pub variable: SimpleVariable,
1303    #[serde(flatten)]
1304    pub rendering: Rendering,
1305    /// Structured link options (DOI, URL).
1306    #[serde(skip_serializing_if = "Option::is_none")]
1307    pub links: Option<crate::options::LinksConfig>,
1308
1309    /// Custom user-defined fields for extensions.
1310    #[serde(skip_serializing_if = "Option::is_none")]
1311    pub custom: Option<HashMap<String, serde_json::Value>>,
1312}
1313
1314/// A locale message call inside a citation or bibliography template.
1315///
1316/// The style chooses the message ID and supplies structured argument sources;
1317/// the active locale owns the natural-language realization in its `messages`
1318/// map.
1319#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1320#[cfg_attr(feature = "schema", derive(JsonSchema))]
1321#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1322pub struct TemplateMessage {
1323    /// Locale message ID to evaluate, such as `pattern.accessed-date`.
1324    pub message: String,
1325    /// Optional term form used when `message` addresses a `term.*` locale item.
1326    #[serde(skip_serializing_if = "Option::is_none")]
1327    pub form: Option<TermForm>,
1328    /// Explicit grammatical gender override for term-backed message selection.
1329    #[serde(skip_serializing_if = "Option::is_none")]
1330    pub gender: Option<GrammaticalGender>,
1331    /// Named argument sources pre-rendered before message evaluation.
1332    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1333    pub args: HashMap<String, MessageArgSource>,
1334    #[serde(flatten, default)]
1335    pub rendering: Rendering,
1336
1337    /// Custom user-defined fields for extensions.
1338    #[serde(skip_serializing_if = "Option::is_none")]
1339    pub custom: Option<HashMap<String, serde_json::Value>>,
1340}
1341
1342/// A structured source for one named locale-message argument.
1343#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1344#[cfg_attr(feature = "schema", derive(JsonSchema))]
1345#[serde(untagged)]
1346pub enum MessageArgSource {
1347    /// A literal string argument.
1348    Literal { literal: String },
1349    /// A rendered contributor argument.
1350    Contributor(Box<TemplateContributor>),
1351    /// A rendered date argument.
1352    Date(TemplateDate),
1353    /// A rendered group argument.
1354    Group(TemplateGroup),
1355    /// A rendered title argument.
1356    Title(TemplateTitle),
1357    /// A rendered number argument.
1358    Number(TemplateNumber),
1359    /// A rendered variable argument.
1360    Variable(TemplateVariable),
1361    /// A rendered locale term argument.
1362    Term(TemplateTerm),
1363}
1364
1365impl MessageArgSource {
1366    /// Convert this argument source into a normal template component when it
1367    /// should be rendered through the standard component pipeline.
1368    #[must_use]
1369    pub fn as_template_component(&self) -> Option<TemplateComponent> {
1370        match self {
1371            Self::Literal { .. } => None,
1372            Self::Contributor(component) => {
1373                Some(TemplateComponent::Contributor(component.as_ref().clone()))
1374            }
1375            Self::Date(component) => Some(TemplateComponent::Date(component.clone())),
1376            Self::Group(component) => Some(TemplateComponent::Group(component.clone())),
1377            Self::Title(component) => Some(TemplateComponent::Title(component.clone())),
1378            Self::Number(component) => Some(TemplateComponent::Number(component.clone())),
1379            Self::Variable(component) => Some(TemplateComponent::Variable(component.clone())),
1380            Self::Term(component) => Some(TemplateComponent::Term(component.clone())),
1381        }
1382    }
1383}
1384
1385/// Simple string variables.
1386///
1387/// Use `variable:` for string passthrough fields, even when the field name is
1388/// also present in [`NumberVariable`]. For example, `variable: volume` keeps the
1389/// source value as plain text, while `number: volume` opts into numeric
1390/// formatting behavior.
1391#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1392#[cfg_attr(feature = "schema", derive(JsonSchema))]
1393#[serde(rename_all = "kebab-case")]
1394#[non_exhaustive]
1395pub enum SimpleVariable {
1396    #[default]
1397    Doi,
1398    Isbn,
1399    Issn,
1400    Url,
1401    Pmid,
1402    Pmcid,
1403    Abstract,
1404    Note,
1405    Annote,
1406    Keyword,
1407    Genre,
1408    RawGenre,
1409    Medium,
1410    RawMedium,
1411    Source,
1412    Status,
1413    Archive,
1414    ArchiveLocation,
1415    ArchiveName,
1416    ArchivePlace,
1417    ArchiveCollection,
1418    ArchiveCollectionId,
1419    ArchiveSeries,
1420    ArchiveBox,
1421    ArchiveFolder,
1422    ArchiveItem,
1423    ArchiveUrl,
1424    EprintId,
1425    EprintServer,
1426    EprintClass,
1427    Publisher,
1428    PublisherPlace,
1429    OriginalPublisher,
1430    OriginalPublisherPlace,
1431    EventTitle,
1432    EventPlace,
1433    Dimensions,
1434    References,
1435    Scale,
1436    Version,
1437    Locator,
1438    ContainerTitleShort,
1439    Authority,
1440    Code,
1441    Reporter,
1442    Page,
1443    Section,
1444    Volume,
1445    Number,
1446    DocketNumber,
1447    PatentNumber,
1448    StandardNumber,
1449    ReportNumber,
1450    AdsBibcode,
1451}
1452
1453/// A term component for rendering locale-specific text.
1454#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1455#[cfg_attr(feature = "schema", derive(JsonSchema))]
1456#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1457pub struct TemplateTerm {
1458    /// Which term to render.
1459    pub term: GeneralTerm,
1460    /// Form: long (default), short, or symbol.
1461    #[serde(skip_serializing_if = "Option::is_none")]
1462    pub form: Option<TermForm>,
1463    /// Explicit grammatical gender override for term selection.
1464    #[serde(skip_serializing_if = "Option::is_none")]
1465    pub gender: Option<GrammaticalGender>,
1466    #[serde(flatten, default)]
1467    pub rendering: Rendering,
1468
1469    /// Custom user-defined fields for extensions.
1470    #[serde(skip_serializing_if = "Option::is_none")]
1471    pub custom: Option<HashMap<String, serde_json::Value>>,
1472}
1473
1474/// Where a [`TemplateTypeLabel`] resolves its text from.
1475///
1476/// `#[non_exhaustive]` with a single variant today: the label always
1477/// describes the reference's own type. Kept as an enum (rather than a bare
1478/// marker field) so a future label source can be added without a schema
1479/// break.
1480#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
1481#[cfg_attr(feature = "schema", derive(JsonSchema))]
1482#[serde(rename_all = "kebab-case")]
1483#[non_exhaustive]
1484pub enum TypeLabelSource {
1485    /// Resolve the label from the reference's own type: prefer its
1486    /// `genre`/`medium`, falling back to a locale term keyed by `ref_type`.
1487    #[default]
1488    ReferenceType,
1489}
1490
1491/// A localized label describing the reference's own type (e.g. "Dataset",
1492/// "Classical work"), resolved from `genre`/`medium` with a locale-term
1493/// fallback keyed by `ref_type`.
1494///
1495/// Emits only the resolved term text — wrap it in `wrap: brackets` (or any
1496/// other `Rendering` option) at the style level to match a particular
1497/// style's presentation, the same as any other component.
1498///
1499/// See `docs/specs/TYPE_CLASSIFICATION_CENTRALIZATION.md`.
1500#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1501#[cfg_attr(feature = "schema", derive(JsonSchema))]
1502#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1503pub struct TemplateTypeLabel {
1504    /// The label's text source. Currently always `reference-type`.
1505    #[serde(rename = "type-label")]
1506    pub type_label: TypeLabelSource,
1507    #[serde(flatten, default)]
1508    pub rendering: Rendering,
1509
1510    /// Custom user-defined fields for extensions.
1511    #[serde(skip_serializing_if = "Option::is_none")]
1512    pub custom: Option<HashMap<String, serde_json::Value>>,
1513}
1514
1515/// A group component for grouping multiple components with a delimiter,
1516/// matching CSL 1.0 `<group>` semantics.
1517#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1518#[cfg_attr(feature = "schema", derive(JsonSchema))]
1519#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1520pub struct TemplateGroup {
1521    pub group: Vec<TemplateComponent>,
1522    /// Optional field-presence condition that controls whether the group renders.
1523    #[serde(skip_serializing_if = "Option::is_none")]
1524    pub render_when: Option<TemplateGroupCondition>,
1525    #[serde(skip_serializing_if = "Option::is_none")]
1526    pub delimiter: Option<DelimiterPunctuation>,
1527    #[serde(flatten, default)]
1528    pub rendering: Rendering,
1529
1530    /// Custom user-defined fields for extensions.
1531    #[serde(skip_serializing_if = "Option::is_none")]
1532    pub custom: Option<HashMap<String, serde_json::Value>>,
1533}
1534
1535/// Field-presence condition for rendering a template group.
1536#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1537#[cfg_attr(feature = "schema", derive(JsonSchema))]
1538#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1539pub struct TemplateGroupCondition {
1540    /// Required field that must be present for the group to render.
1541    #[serde(skip_serializing_if = "Option::is_none")]
1542    pub field_present: Option<TemplateConditionField>,
1543    /// Required field that must be absent for the group to render.
1544    #[serde(skip_serializing_if = "Option::is_none")]
1545    pub field_absent: Option<TemplateConditionField>,
1546}
1547
1548/// Reference fields that can be tested by a template group condition.
1549#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
1550#[cfg_attr(feature = "schema", derive(JsonSchema))]
1551#[serde(rename_all = "kebab-case")]
1552pub enum TemplateConditionField {
1553    /// The primary author contributor.
1554    Author,
1555    /// The editor contributor.
1556    Editor,
1557    /// The recipient contributor.
1558    Recipient,
1559    /// The translator contributor.
1560    Translator,
1561    /// The primary title.
1562    Title,
1563    /// The series or collection title.
1564    CollectionTitle,
1565    /// The issued date.
1566    Issued,
1567    /// The original publication date.
1568    OriginalPublished,
1569    /// The publisher name.
1570    Publisher,
1571    /// The original publisher name (e.g. a reprint's first publisher).
1572    OriginalPublisher,
1573    /// The original publisher place (e.g. a reprint's first place of publication).
1574    OriginalPublisherPlace,
1575    /// The original title (e.g. a translation's title in its source language).
1576    OriginalTitle,
1577    /// The DOI identifier.
1578    Doi,
1579    /// The reference genre or item type label.
1580    Genre,
1581    /// The archive or repository name.
1582    Archive,
1583    /// The archive shelfmark or repository location.
1584    ArchiveLocation,
1585}
1586
1587/// Delimiter punctuation options.
1588#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)]
1589#[serde(rename_all = "kebab-case")]
1590pub enum DelimiterPunctuation {
1591    #[default]
1592    Comma,
1593    Semicolon,
1594    Period,
1595    Colon,
1596    Ampersand,
1597    VerticalLine,
1598    Slash,
1599    Hyphen,
1600    Space,
1601    None,
1602    /// Custom delimiter string (e.g., ": ").
1603    #[serde(untagged)]
1604    Custom(String),
1605}
1606
1607#[cfg(feature = "schema")]
1608impl JsonSchema for DelimiterPunctuation {
1609    fn schema_name() -> std::borrow::Cow<'static, str> {
1610        "DelimiterPunctuation".into()
1611    }
1612
1613    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1614        schemars::json_schema!({"type": "string", "description": "Delimiter punctuation options."})
1615    }
1616}
1617
1618impl DelimiterPunctuation {
1619    /// Convert this delimiter to a string with trailing space.
1620    ///
1621    /// Returns the punctuation followed by a space, except for Space (single space) and None (empty string).
1622    pub fn to_string_with_space(&self) -> String {
1623        match self {
1624            Self::Comma => ", ".to_string(),
1625            Self::Semicolon => "; ".to_string(),
1626            Self::Period => ". ".to_string(),
1627            Self::Colon => ": ".to_string(),
1628            Self::Ampersand => " & ".to_string(),
1629            Self::VerticalLine => " | ".to_string(),
1630            Self::Slash => "/".to_string(),
1631            Self::Hyphen => "-".to_string(),
1632            Self::Space => " ".to_string(),
1633            Self::None => "".to_string(),
1634            Self::Custom(s) => s.clone(),
1635        }
1636    }
1637
1638    /// Parse a delimiter from a CSL 1.0 delimiter string.
1639    ///
1640    /// Handles common patterns like ", ", ": ", etc.
1641    /// Returns the Custom variant for unrecognized delimiters.
1642    pub fn from_csl_string(s: &str) -> Self {
1643        if s == " " {
1644            return Self::Space;
1645        }
1646
1647        let trimmed = s.trim();
1648        if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
1649            return Self::None;
1650        }
1651
1652        match trimmed {
1653            "," => Self::Comma,
1654            ";" => Self::Semicolon,
1655            "." => Self::Period,
1656            ":" => Self::Colon,
1657            "&" => Self::Ampersand,
1658            "|" => Self::VerticalLine,
1659            "/" => Self::Slash,
1660            "-" => Self::Hyphen,
1661            _ => Self::Custom(s.to_string()),
1662        }
1663    }
1664}
1665
1666#[cfg(test)]
1667#[allow(
1668    clippy::unwrap_used,
1669    clippy::expect_used,
1670    clippy::panic,
1671    clippy::indexing_slicing,
1672    clippy::todo,
1673    clippy::unimplemented,
1674    clippy::unreachable,
1675    clippy::get_unwrap,
1676    reason = "Panicking is acceptable and often desired in tests."
1677)]
1678mod tests {
1679    use super::*;
1680
1681    #[test]
1682    fn test_contributor_deserialization() {
1683        let yaml = r#"
1684contributor: author
1685form: long
1686"#;
1687        let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1688        assert_eq!(comp.contributor, ContributorRole::Author);
1689        assert_eq!(comp.form, ContributorForm::Long);
1690    }
1691
1692    #[test]
1693    fn test_contributor_name_order_family_first_except_last_deserialization() {
1694        let yaml = r#"
1695contributor: author
1696form: long
1697name-order: family-first-except-last
1698"#;
1699        let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1700        assert_eq!(comp.name_order, Some(NameOrder::FamilyFirstExceptLast));
1701    }
1702
1703    #[test]
1704    fn test_template_component_untagged() {
1705        let yaml = r#"
1706- contributor: author
1707  form: short
1708- date: issued
1709  form: year
1710- title: primary
1711"#;
1712        let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1713        assert_eq!(components.len(), 3);
1714
1715        match &components[0] {
1716            TemplateComponent::Contributor(c) => {
1717                assert_eq!(c.contributor, ContributorRole::Author);
1718            }
1719            _ => panic!("Expected Contributor"),
1720        }
1721
1722        match &components[1] {
1723            TemplateComponent::Date(d) => {
1724                assert_eq!(d.date, DateVariable::Issued);
1725            }
1726            _ => panic!("Expected Date"),
1727        }
1728    }
1729
1730    #[test]
1731    fn test_flattened_rendering() {
1732        // Test that rendering options can be specified directly on the component
1733        let yaml = r#"
1734- title: parent-monograph
1735  prefix: "In "
1736  emph: true
1737- date: issued
1738  form: year
1739  wrap: parentheses
1740"#;
1741        let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1742        assert_eq!(components.len(), 2);
1743
1744        match &components[0] {
1745            TemplateComponent::Title(t) => {
1746                assert_eq!(t.rendering.prefix, Some("In ".to_string()));
1747                assert_eq!(t.rendering.emph, Some(true));
1748            }
1749            _ => panic!("Expected Title"),
1750        }
1751
1752        match &components[1] {
1753            TemplateComponent::Date(d) => {
1754                assert_eq!(
1755                    d.rendering.wrap,
1756                    Some(WrapConfig {
1757                        punctuation: WrapPunctuation::Parentheses,
1758                        inner_prefix: None,
1759                        inner_suffix: None,
1760                    })
1761                );
1762            }
1763            _ => panic!("Expected Date"),
1764        }
1765    }
1766
1767    #[test]
1768    fn test_number_variable_custom_normalizes_manual_construction() {
1769        let number = NumberVariable::Custom("Reel Label".to_string());
1770
1771        assert_eq!(number.as_key(), "reel-label");
1772        assert_eq!(
1773            number,
1774            serde_yaml::from_str::<NumberVariable>("reel-label")
1775                .expect("custom number variable should parse")
1776        );
1777        assert_eq!(
1778            serde_json::to_string(&number).expect("custom number variable should serialize"),
1779            "\"reel-label\""
1780        );
1781    }
1782
1783    #[test]
1784    fn test_contributor_with_wrap() {
1785        let yaml = r#"
1786contributor: publisher
1787form: short
1788wrap: parentheses
1789"#;
1790        let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1791        assert_eq!(comp.contributor, ContributorRole::Publisher);
1792        assert_eq!(
1793            comp.rendering.wrap,
1794            Some(WrapConfig {
1795                punctuation: WrapPunctuation::Parentheses,
1796                inner_prefix: None,
1797                inner_suffix: None,
1798            })
1799        );
1800    }
1801
1802    #[test]
1803    fn test_variable_deserialization() {
1804        // Test that `variable: publisher` parses as Variable, not Number
1805        let yaml = "variable: publisher\n";
1806        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1807        match comp {
1808            TemplateComponent::Variable(v) => {
1809                assert_eq!(v.variable, SimpleVariable::Publisher);
1810            }
1811            _ => panic!("Expected Variable(Publisher), got {:?}", comp),
1812        }
1813    }
1814
1815    #[test]
1816    fn test_message_component_deserialization() {
1817        let yaml = r#"
1818message: pattern.in-container
1819args:
1820  container:
1821    group:
1822    - title: parent-monograph
1823      emph: true
1824text-case: capitalize-first
1825"#;
1826        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1827
1828        match comp {
1829            TemplateComponent::Message(message) => {
1830                assert_eq!(message.message, "pattern.in-container");
1831                assert!(matches!(
1832                    message.args.get("container"),
1833                    Some(MessageArgSource::Group(group)) if group.group.len() == 1
1834                        && matches!(
1835                            group.group.first(),
1836                            Some(TemplateComponent::Title(title))
1837                                if title.title == TitleType::ParentMonograph
1838                                    && title.rendering.emph == Some(true)
1839                        )
1840                ));
1841                assert_eq!(
1842                    message.rendering.text_case,
1843                    Some(crate::options::titles::TextCase::CapitalizeFirst)
1844                );
1845            }
1846            _ => panic!("Expected Message component, got {comp:?}"),
1847        }
1848    }
1849
1850    #[test]
1851    fn test_term_backed_message_component_deserializes_form() {
1852        let yaml = r#"
1853message: term.in
1854form: long
1855suffix: ":"
1856"#;
1857        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1858
1859        match comp {
1860            TemplateComponent::Message(message) => {
1861                assert_eq!(message.message, "term.in");
1862                assert_eq!(message.form, Some(TermForm::Long));
1863                assert_eq!(message.rendering.suffix.as_deref(), Some(":"));
1864            }
1865            _ => panic!("Expected Message component, got {comp:?}"),
1866        }
1867    }
1868
1869    #[test]
1870    fn test_group_deserializes_term_backed_message_component_with_form() {
1871        let yaml = r#"
1872group:
1873- message: term.in
1874  form: long
1875  suffix: ":"
1876- title: parent-monograph
1877"#;
1878        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1879
1880        match comp {
1881            TemplateComponent::Group(group) => {
1882                assert!(matches!(
1883                    group.group.first(),
1884                    Some(TemplateComponent::Message(message))
1885                        if message.message == "term.in"
1886                            && message.form == Some(TermForm::Long)
1887                            && message.rendering.suffix.as_deref() == Some(":")
1888                ));
1889            }
1890            _ => panic!("Expected Group component, got {comp:?}"),
1891        }
1892    }
1893
1894    #[test]
1895    fn test_variable_array_parsing() {
1896        let yaml = r#"
1897- variable: doi
1898  prefix: "https://doi.org/"
1899- variable: publisher
1900"#;
1901        let comps: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1902        assert_eq!(comps.len(), 2);
1903        match &comps[0] {
1904            TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Doi),
1905            _ => panic!("Expected Variable for doi, got {:?}", comps[0]),
1906        }
1907        match &comps[1] {
1908            TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Publisher),
1909            _ => panic!("Expected Variable for publisher, got {:?}", comps[1]),
1910        }
1911    }
1912
1913    #[test]
1914    fn test_type_selector_default_only_matches_default_context() {
1915        let selector = TypeSelector::Single("default".to_string());
1916        assert!(selector.matches("default"));
1917        assert!(!selector.matches("article-journal"));
1918
1919        let mixed = TypeSelector::Multiple(vec!["default".to_string(), "chapter".to_string()]);
1920        assert!(mixed.matches("default"));
1921        assert!(mixed.matches("chapter"));
1922        assert!(!mixed.matches("book"));
1923    }
1924
1925    #[test]
1926    fn test_template_component_selector_matches_nested_partial_group() {
1927        let component: TemplateComponent = serde_yaml::from_str(
1928            r#"
1929delimiter: ""
1930group:
1931- number: citation-number
1932  wrap:
1933    punctuation: brackets
1934- contributor: author
1935  form: long
1936"#,
1937        )
1938        .unwrap();
1939        let selector = TemplateComponentSelector {
1940            fields: BTreeMap::from([(
1941                "group".to_string(),
1942                serde_json::json!([
1943                    { "number": "citation-number" },
1944                    { "contributor": "author" }
1945                ]),
1946            )]),
1947        };
1948
1949        assert!(selector.matches(&component));
1950    }
1951
1952    #[test]
1953    fn test_delimiter_from_csl_string_normalizes_none_and_trimmed_values() {
1954        assert_eq!(
1955            DelimiterPunctuation::from_csl_string("none"),
1956            DelimiterPunctuation::None
1957        );
1958        assert_eq!(
1959            DelimiterPunctuation::from_csl_string(" none "),
1960            DelimiterPunctuation::None
1961        );
1962        assert_eq!(
1963            DelimiterPunctuation::from_csl_string(" "),
1964            DelimiterPunctuation::Space
1965        );
1966        assert_eq!(
1967            DelimiterPunctuation::from_csl_string(" : "),
1968            DelimiterPunctuation::Colon
1969        );
1970    }
1971}