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
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/// A named template (reusable sequence of components).
53pub type Template = Vec<TemplateComponent>;
54
55/// Type-specific template variants keyed by reference-type selector.
56pub type TemplateVariants = IndexMap<TypeSelector, TemplateVariant>;
57
58/// Rendering instructions applied to template components.
59///
60/// These fields are flattened into parent structs, so in YAML you write:
61/// ```yaml
62/// - title: primary
63///   emph: true
64///   prefix: "In "
65/// ```
66/// Rather than nesting under a `rendering:` key.
67#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
68#[cfg_attr(feature = "schema", derive(JsonSchema))]
69#[serde(rename_all = "kebab-case", default)]
70pub struct Rendering {
71    /// Text-case transform to apply to the rendered value.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub text_case: Option<crate::options::titles::TextCase>,
74    /// Render in italics/emphasis.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub emph: Option<bool>,
77    /// Render in quotes.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub quote: Option<bool>,
80    /// Render in bold/strong.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub strong: Option<bool>,
83    /// Render in small caps.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub small_caps: Option<bool>,
86    /// Vertical alignment to apply to rendered output.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub vertical_align: Option<crate::VerticalAlign>,
89    /// Text to prepend to the rendered value (outside any wrap).
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub prefix: Option<String>,
92    /// Text to append to the rendered value (outside any wrap).
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub suffix: Option<String>,
95    /// Wrapping punctuation and optional inner affixes (text inside the wrap).
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub wrap: Option<WrapConfig>,
98    /// If true, suppress this component entirely (render as empty string).
99    /// Useful for type-specific overrides like suppressing publisher for journals.
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub suppress: Option<bool>,
102    /// Override name initialization (e.g., ". " or "").
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub initialize_with: Option<String>,
105    /// Override name form (e.g., initials, full, family-only).
106    #[serde(skip_serializing_if = "Option::is_none", rename = "name-form")]
107    pub name_form: Option<crate::options::contributors::NameForm>,
108    /// Strip trailing periods from rendered value.
109    #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
110    pub strip_periods: Option<bool>,
111}
112
113impl Rendering {
114    /// Merge another rendering configuration into this one.
115    ///
116    /// The other rendering takes precedence, overwriting any fields that are present.
117    pub fn merge(&mut self, other: &Rendering) {
118        crate::merge_options!(
119            self,
120            other,
121            text_case,
122            emph,
123            quote,
124            strong,
125            small_caps,
126            vertical_align,
127            prefix,
128            suffix,
129            wrap,
130            suppress,
131            initialize_with,
132            name_form,
133            strip_periods,
134        );
135    }
136}
137
138/// Punctuation to wrap a component in.
139#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
140#[cfg_attr(feature = "schema", derive(JsonSchema))]
141#[serde(rename_all = "kebab-case")]
142pub enum WrapPunctuation {
143    #[default]
144    Parentheses,
145    Brackets,
146    Quotes,
147}
148
149/// Wrapping punctuation and optional inner affixes applied around a rendered value.
150///
151/// Combines the wrap punctuation with optional text that appears inside the wrap
152/// (between the wrap character and the rendered content).
153#[derive(Debug, Clone, PartialEq, Serialize)]
154#[cfg_attr(feature = "schema", derive(JsonSchema))]
155#[serde(rename_all = "kebab-case")]
156pub struct WrapConfig {
157    /// The wrapping punctuation style.
158    pub punctuation: WrapPunctuation,
159    /// Text inserted after the opening wrap character but before the content.
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub inner_prefix: Option<String>,
162    /// Text inserted after the content but before the closing wrap character.
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub inner_suffix: Option<String>,
165}
166
167impl<'de> serde::Deserialize<'de> for WrapConfig {
168    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
169        struct WrapConfigVisitor;
170
171        impl<'de> serde::de::Visitor<'de> for WrapConfigVisitor {
172            type Value = WrapConfig;
173
174            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
175                write!(
176                    f,
177                    "a wrap punctuation string or a mapping with a 'punctuation' key"
178                )
179            }
180
181            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<WrapConfig, E> {
182                let punctuation = match v {
183                    "parentheses" => WrapPunctuation::Parentheses,
184                    "brackets" => WrapPunctuation::Brackets,
185                    "quotes" => WrapPunctuation::Quotes,
186                    other => {
187                        return Err(E::unknown_variant(
188                            other,
189                            &["parentheses", "brackets", "quotes"],
190                        ));
191                    }
192                };
193                Ok(WrapConfig {
194                    punctuation,
195                    inner_prefix: None,
196                    inner_suffix: None,
197                })
198            }
199
200            fn visit_map<A: serde::de::MapAccess<'de>>(
201                self,
202                mut map: A,
203            ) -> Result<WrapConfig, A::Error> {
204                let mut punctuation: Option<WrapPunctuation> = None;
205                let mut inner_prefix: Option<String> = None;
206                let mut inner_suffix: Option<String> = None;
207
208                while let Some(key) = map.next_key::<String>()? {
209                    match key.as_str() {
210                        "punctuation" => {
211                            punctuation = Some(map.next_value()?);
212                        }
213                        "inner-prefix" => {
214                            inner_prefix = Some(map.next_value()?);
215                        }
216                        "inner-suffix" => {
217                            inner_suffix = Some(map.next_value()?);
218                        }
219                        other => {
220                            return Err(serde::de::Error::unknown_field(
221                                other,
222                                &["punctuation", "inner-prefix", "inner-suffix"],
223                            ));
224                        }
225                    }
226                }
227
228                let punctuation =
229                    punctuation.ok_or_else(|| serde::de::Error::missing_field("punctuation"))?;
230                Ok(WrapConfig {
231                    punctuation,
232                    inner_prefix,
233                    inner_suffix,
234                })
235            }
236        }
237
238        deserializer.deserialize_any(WrapConfigVisitor)
239    }
240}
241
242impl From<WrapPunctuation> for WrapConfig {
243    fn from(punctuation: WrapPunctuation) -> Self {
244        WrapConfig {
245            punctuation,
246            inner_prefix: None,
247            inner_suffix: None,
248        }
249    }
250}
251
252/// Canonical reference type names recognized by the Citum engine.
253///
254/// Used by [`validate_type_name`] to detect likely typos.
255pub const VALID_TYPE_NAMES: &[&str] = &[
256    "book",
257    "manual",
258    "report",
259    "thesis",
260    "webpage",
261    "post",
262    "interview",
263    "manuscript",
264    "personal-communication",
265    "document",
266    "chapter",
267    "paper-conference",
268    "article-journal",
269    "article-magazine",
270    "article-newspaper",
271    "broadcast",
272    "motion-picture",
273    "collection",
274    "legal-case",
275    "statute",
276    "treaty",
277    "hearing",
278    "regulation",
279    "brief",
280    "classic",
281    "patent",
282    "dataset",
283    "standard",
284    "software",
285    // Special keywords
286    "all",
287    "default",
288];
289
290/// Returns `true` if `s` is a recognized reference type name.
291///
292/// Normalizes underscores to hyphens before comparing, so both
293/// `"article_journal"` and `"article-journal"` are accepted.
294/// Returns `false` for unrecognized names (likely typos).
295pub fn validate_type_name(s: &str) -> bool {
296    let normalized = s.replace('_', "-");
297    VALID_TYPE_NAMES.iter().any(|&known| known == normalized)
298}
299
300/// Selector for reference types in overrides.
301/// Can be a single type string or a list of types.
302#[derive(Debug, Clone, PartialEq, Eq, Hash)]
303#[cfg_attr(feature = "schema", derive(JsonSchema))]
304pub enum TypeSelector {
305    Single(String),
306    Multiple(Vec<String>),
307}
308
309impl Serialize for TypeSelector {
310    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
311    where
312        S: serde::Serializer,
313    {
314        serializer.serialize_str(&self.to_string())
315    }
316}
317
318impl<'de> Deserialize<'de> for TypeSelector {
319    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
320    where
321        D: serde::Deserializer<'de>,
322    {
323        struct Visitor;
324        impl<'de> serde::de::Visitor<'de> for Visitor {
325            type Value = TypeSelector;
326
327            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
328                formatter.write_str("a string or a sequence of strings")
329            }
330
331            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
332            where
333                E: serde::de::Error,
334            {
335                v.parse().map_err(E::custom)
336            }
337
338            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
339            where
340                A: serde::de::SeqAccess<'de>,
341            {
342                let mut types = Vec::new();
343                while let Some(t) = seq.next_element::<String>()? {
344                    types.push(t);
345                }
346                if types.len() == 1 {
347                    Ok(TypeSelector::Single(types.remove(0)))
348                } else {
349                    Ok(TypeSelector::Multiple(types))
350                }
351            }
352        }
353        deserializer.deserialize_any(Visitor)
354    }
355}
356
357impl std::fmt::Display for TypeSelector {
358    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359        match self {
360            TypeSelector::Single(s) => write!(f, "{s}"),
361            TypeSelector::Multiple(types) => write!(f, "{}", types.join(",")),
362        }
363    }
364}
365
366impl std::str::FromStr for TypeSelector {
367    type Err = std::convert::Infallible;
368
369    fn from_str(s: &str) -> Result<Self, Self::Err> {
370        if s.contains(',') {
371            Ok(TypeSelector::Multiple(
372                s.split(',').map(|t| t.trim().to_string()).collect(),
373            ))
374        } else {
375            Ok(TypeSelector::Single(s.to_string()))
376        }
377    }
378}
379
380impl TypeSelector {
381    /// Check whether this selector matches a reference type.
382    ///
383    /// Type names are compared after normalizing underscores to hyphens, so
384    /// "legal_case" and "legal-case" are treated as equivalent (matching both
385    /// CSL 1.0 underscore convention and Citum hyphen convention).
386    ///
387    /// The special keyword "all" always matches any reference type.
388    pub fn matches(&self, ref_type: &str) -> bool {
389        let normalized_ref = ref_type.replace('_', "-");
390        let base_ref = normalized_ref
391            .split_once('+')
392            .map(|(base, _)| base)
393            .unwrap_or(&normalized_ref);
394        let eq = |s: &str| -> bool {
395            s == ref_type
396                || s.replace('_', "-") == normalized_ref
397                || s.replace('_', "-") == base_ref
398                || s == "all"
399                || (s == "default" && ref_type == "default")
400        };
401        match self {
402            TypeSelector::Single(s) => eq(s),
403            TypeSelector::Multiple(types) => types.iter().any(|t| eq(t)),
404        }
405    }
406
407    /// Returns any type names in this selector that are not in [`VALID_TYPE_NAMES`].
408    ///
409    /// An empty vec means all names are valid. Callers should emit a
410    /// [`crate::SchemaWarning`] for each returned name.
411    pub fn unknown_type_names(&self) -> Vec<&str> {
412        match self {
413            TypeSelector::Single(s) => {
414                if validate_type_name(s) {
415                    vec![]
416                } else {
417                    vec![s.as_str()]
418                }
419            }
420            TypeSelector::Multiple(types) => types
421                .iter()
422                .filter(|s| !validate_type_name(s))
423                .map(|s| s.as_str())
424                .collect(),
425        }
426    }
427}
428
429/// A template component - the building blocks of citation/bibliography templates.
430///
431/// Each variant handles a specific data type with appropriate formatting options.
432#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
433#[cfg_attr(feature = "schema", derive(JsonSchema))]
434#[serde(untagged)]
435#[non_exhaustive]
436pub enum TemplateComponent {
437    Contributor(TemplateContributor),
438    Date(TemplateDate),
439    Title(TemplateTitle),
440    Number(TemplateNumber),
441    Variable(TemplateVariable),
442    Group(TemplateGroup),
443    Term(TemplateTerm),
444}
445
446impl Default for TemplateComponent {
447    fn default() -> Self {
448        TemplateComponent::Variable(TemplateVariable::default())
449    }
450}
451
452impl TemplateComponent {
453    /// Return the rendering options for this component.
454    ///
455    /// Every template component has rendering options like emphasis, wrapping, and prefixes.
456    pub fn rendering(&self) -> &Rendering {
457        crate::dispatch_component!(self, |inner| &inner.rendering)
458    }
459
460    /// Return the mutable rendering options for this component.
461    ///
462    /// Provides mutable access to rendering fields (prefix, suffix, etc.)
463    /// that are present on all template component variants.
464    pub fn rendering_mut(&mut self) -> &mut Rendering {
465        crate::dispatch_component!(self, |inner| &mut inner.rendering)
466    }
467}
468
469/// Type-specific template override, either as a complete legacy template or a V3 diff.
470#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
471#[cfg_attr(feature = "schema", derive(JsonSchema))]
472#[serde(untagged)]
473pub enum TemplateVariant {
474    /// Complete replacement template used by Template V1/V2 styles.
475    Full(Vec<TemplateComponent>),
476    /// Structural diff applied to a parent template during style resolution.
477    Diff(TemplateVariantDiff),
478}
479
480impl TemplateVariant {
481    /// Return this variant as a concrete template if it has already been resolved.
482    #[must_use]
483    pub fn as_template(&self) -> Option<&[TemplateComponent]> {
484        match self {
485            Self::Full(template) => Some(template.as_slice()),
486            Self::Diff(_) => None,
487        }
488    }
489
490    /// Return this variant as a mutable concrete template if it has already been resolved.
491    pub fn as_template_mut(&mut self) -> Option<&mut Vec<TemplateComponent>> {
492        match self {
493            Self::Full(template) => Some(template),
494            Self::Diff(_) => None,
495        }
496    }
497
498    /// Convert this variant into its concrete template if it has already been resolved.
499    #[must_use]
500    pub fn into_template(self) -> Option<Vec<TemplateComponent>> {
501        match self {
502            Self::Full(template) => Some(template),
503            Self::Diff(_) => None,
504        }
505    }
506}
507
508impl From<Vec<TemplateComponent>> for TemplateVariant {
509    fn from(template: Vec<TemplateComponent>) -> Self {
510        Self::Full(template)
511    }
512}
513
514/// Structural diff that derives a type-specific template from a parent template.
515#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
516#[cfg_attr(feature = "schema", derive(JsonSchema))]
517#[serde(rename_all = "kebab-case", deny_unknown_fields)]
518pub struct TemplateVariantDiff {
519    /// Optional parent type variant selector within the same section.
520    #[serde(skip_serializing_if = "Option::is_none")]
521    pub extends: Option<TypeSelector>,
522    /// Rendering-only modifications applied in authored order.
523    #[serde(skip_serializing_if = "Vec::is_empty", default)]
524    pub modify: Vec<TemplateModifyOperation>,
525    /// Component removals applied in authored order.
526    #[serde(skip_serializing_if = "Vec::is_empty", default)]
527    pub remove: Vec<TemplateRemoveOperation>,
528    /// Component additions applied in authored order.
529    #[serde(skip_serializing_if = "Vec::is_empty", default)]
530    pub add: Vec<TemplateAddOperation>,
531}
532
533/// Partial component selector used to locate anchors in a template.
534#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
535#[cfg_attr(feature = "schema", derive(JsonSchema))]
536#[serde(transparent)]
537pub struct TemplateComponentSelector {
538    /// Component fields that must be present with equal values on the target component.
539    pub fields: BTreeMap<String, serde_json::Value>,
540}
541
542impl TemplateComponentSelector {
543    /// Returns `true` when this selector has no fields.
544    #[must_use]
545    pub fn is_empty(&self) -> bool {
546        self.fields.is_empty()
547    }
548
549    /// Returns `true` when every selector field is present with the same value.
550    #[must_use]
551    pub fn matches(&self, component: &TemplateComponent) -> bool {
552        let Ok(serde_json::Value::Object(component_fields)) = serde_json::to_value(component)
553        else {
554            return false;
555        };
556
557        self.fields.iter().all(|(key, expected)| {
558            component_fields
559                .get(key)
560                .is_some_and(|actual| selector_value_matches(expected, actual))
561        })
562    }
563}
564
565fn selector_value_matches(expected: &serde_json::Value, actual: &serde_json::Value) -> bool {
566    match (expected, actual) {
567        (serde_json::Value::Object(expected_fields), serde_json::Value::Object(actual_fields)) => {
568            expected_fields.iter().all(|(key, expected_value)| {
569                actual_fields.get(key).is_some_and(|actual_value| {
570                    selector_value_matches(expected_value, actual_value)
571                })
572            })
573        }
574        (serde_json::Value::Array(expected_items), serde_json::Value::Array(actual_items)) => {
575            expected_items.len() == actual_items.len()
576                && expected_items.iter().zip(actual_items.iter()).all(
577                    |(expected_item, actual_item)| {
578                        selector_value_matches(expected_item, actual_item)
579                    },
580                )
581        }
582        _ => expected == actual,
583    }
584}
585
586/// Rendering-only modification for the component matched by `match`.
587#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
588#[cfg_attr(feature = "schema", derive(JsonSchema))]
589#[serde(rename_all = "kebab-case", deny_unknown_fields)]
590pub struct TemplateModifyOperation {
591    /// Selector identifying exactly one component to modify.
592    #[serde(rename = "match")]
593    pub match_selector: TemplateComponentSelector,
594    /// Override the localized number label form when modifying number components.
595    #[serde(skip_serializing_if = "Option::is_none")]
596    pub label_form: Option<LabelForm>,
597    /// Rendering fields to merge onto the matched component.
598    #[serde(flatten, default)]
599    pub rendering: Rendering,
600}
601
602/// Removal operation for the component matched by `match`.
603#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
604#[cfg_attr(feature = "schema", derive(JsonSchema))]
605#[serde(rename_all = "kebab-case", deny_unknown_fields)]
606pub struct TemplateRemoveOperation {
607    /// Selector identifying exactly one component to remove.
608    #[serde(rename = "match")]
609    pub match_selector: TemplateComponentSelector,
610}
611
612/// Addition operation that inserts a component before or after an anchor.
613#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
614#[cfg_attr(feature = "schema", derive(JsonSchema))]
615#[serde(rename_all = "kebab-case", deny_unknown_fields)]
616pub struct TemplateAddOperation {
617    /// Anchor selector before which the component should be inserted.
618    #[serde(skip_serializing_if = "Option::is_none")]
619    pub before: Option<TemplateComponentSelector>,
620    /// Anchor selector after which the component should be inserted.
621    #[serde(skip_serializing_if = "Option::is_none")]
622    pub after: Option<TemplateComponentSelector>,
623    /// Component to insert.
624    pub component: TemplateComponent,
625}
626
627/// Configuration for role labels (e.g., "eds.", "trans.").
628#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
629#[cfg_attr(feature = "schema", derive(JsonSchema))]
630#[serde(rename_all = "kebab-case")]
631pub struct RoleLabel {
632    /// Locale term key for the role (e.g., "editor", "translator").
633    pub term: String,
634    /// Term form: short ("eds.") or long ("editors").
635    #[serde(default)]
636    pub form: RoleLabelForm,
637    /// Where to place the label relative to names.
638    #[serde(default)]
639    pub placement: LabelPlacement,
640}
641
642/// Term form for role labels.
643#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
644#[cfg_attr(feature = "schema", derive(JsonSchema))]
645#[serde(rename_all = "kebab-case")]
646pub enum RoleLabelForm {
647    #[default]
648    Short,
649    Long,
650}
651
652/// Label placement relative to contributor names.
653#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
654#[cfg_attr(feature = "schema", derive(JsonSchema))]
655#[serde(rename_all = "kebab-case")]
656pub enum LabelPlacement {
657    Prefix,
658    #[default]
659    Suffix,
660}
661
662/// A contributor component for rendering names.
663#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
664#[cfg_attr(feature = "schema", derive(JsonSchema))]
665#[serde(rename_all = "kebab-case", deny_unknown_fields)]
666pub struct TemplateContributor {
667    /// Which contributor role to render (author, editor, etc.).
668    pub contributor: ContributorRole,
669    /// How to display the contributor (long names, short, with label, etc.).
670    pub form: ContributorForm,
671    /// Optional role label configuration (e.g., "eds." for editors).
672    #[serde(skip_serializing_if = "Option::is_none")]
673    pub label: Option<RoleLabel>,
674    /// Override the global name order for this specific component.
675    /// Use to show editors as "Given Family" even when global setting is "Family, Given".
676    #[serde(skip_serializing_if = "Option::is_none")]
677    pub name_order: Option<NameOrder>,
678    /// Override the name form (e.g., initials, full, family-only) for this specific component.
679    #[serde(skip_serializing_if = "Option::is_none", rename = "name-form")]
680    pub name_form: Option<crate::options::contributors::NameForm>,
681    /// Custom delimiter between names (overrides global setting).
682    #[serde(skip_serializing_if = "Option::is_none")]
683    pub delimiter: Option<String>,
684    /// Delimiter between family and given name when inverted (overrides global setting).
685    #[serde(skip_serializing_if = "Option::is_none")]
686    pub sort_separator: Option<String>,
687    /// Shorten the list of names (et al. configuration).
688    #[serde(skip_serializing_if = "Option::is_none")]
689    pub shorten: Option<crate::options::ShortenListOptions>,
690    /// Override the conjunction between the last two names.
691    /// Use `none` for bibliography when citation uses `text` or `symbol`.
692    #[serde(skip_serializing_if = "Option::is_none")]
693    pub and: Option<crate::options::AndOptions>,
694    #[serde(flatten, default)]
695    pub rendering: Rendering,
696    /// Structured link options (DOI, URL).
697    #[serde(skip_serializing_if = "Option::is_none")]
698    pub links: Option<crate::options::LinksConfig>,
699    /// Explicit grammatical gender override for role-label agreement.
700    #[serde(skip_serializing_if = "Option::is_none")]
701    pub gender: Option<GrammaticalGender>,
702
703    /// Custom user-defined fields for extensions.
704    #[serde(skip_serializing_if = "Option::is_none")]
705    pub custom: Option<HashMap<String, serde_json::Value>>,
706}
707
708/// Name display order.
709#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
710#[cfg_attr(feature = "schema", derive(JsonSchema))]
711#[serde(rename_all = "kebab-case")]
712pub enum NameOrder {
713    /// Display as "Given Family" (e.g., "John Smith").
714    GivenFirst,
715    /// Display as "Family, Given" (e.g., "Smith, John").
716    #[default]
717    FamilyFirst,
718    /// First contributor inverted ("Family, Given"); subsequent contributors given-first.
719    FamilyFirstOnly,
720}
721
722/// How to render contributor names.
723#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
724#[cfg_attr(feature = "schema", derive(JsonSchema))]
725#[serde(rename_all = "kebab-case")]
726pub enum ContributorForm {
727    #[default]
728    Long,
729    Short,
730    FamilyOnly,
731    Verb,
732    VerbShort,
733}
734
735crate::str_enum! {
736    /// Contributor roles.
737    #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
738    pub enum ContributorRole {
739        #[default] Author = "author",
740        Chair = "chair",
741        Editor = "editor",
742        Translator = "translator",
743        Director = "director",
744        Publisher = "publisher",
745        Recipient = "recipient",
746        Interviewer = "interviewer",
747        Interviewee = "interviewee",
748        Guest = "guest",
749        Inventor = "inventor",
750        Counsel = "counsel",
751        Composer = "composer",
752        CollectionEditor = "collection-editor",
753        ContainerAuthor = "container-author",
754        EditorialDirector = "editorial-director",
755        TextualEditor = "textual-editor",
756        Illustrator = "illustrator",
757        OriginalAuthor = "original-author",
758        ReviewedAuthor = "reviewed-author"
759    }
760}
761
762/// A date component for rendering dates.
763#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
764#[cfg_attr(feature = "schema", derive(JsonSchema))]
765#[serde(rename_all = "kebab-case", deny_unknown_fields)]
766pub struct TemplateDate {
767    pub date: DateVariable,
768    pub form: DateForm,
769    /// Fallback components if the primary date is missing.
770    #[serde(skip_serializing_if = "Option::is_none")]
771    pub fallback: Option<Vec<TemplateComponent>>,
772    #[serde(flatten, default)]
773    pub rendering: Rendering,
774    /// Structured link options (DOI, URL).
775    #[serde(skip_serializing_if = "Option::is_none")]
776    pub links: Option<crate::options::LinksConfig>,
777
778    /// Custom user-defined fields for extensions.
779    #[serde(skip_serializing_if = "Option::is_none")]
780    pub custom: Option<HashMap<String, serde_json::Value>>,
781}
782
783/// Date variables.
784#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
785#[cfg_attr(feature = "schema", derive(JsonSchema))]
786#[serde(rename_all = "kebab-case")]
787pub enum DateVariable {
788    #[default]
789    Issued,
790    Accessed,
791    OriginalPublished,
792    Submitted,
793    EventDate,
794}
795
796crate::str_enum! {
797    /// Date rendering forms.
798    #[derive(Debug, Default, Clone, PartialEq)]
799    pub enum DateForm {
800        #[default]
801        Year = "year",
802        YearMonth = "year-month",
803        Full = "full",
804        MonthDay = "month-day",
805        YearMonthDay = "year-month-day",
806        DayMonthAbbrYear = "day-month-abbr-year",
807        /// Abbreviated month + day + year in US order: "Jan 15, 2024".
808        MonthAbbrDayYear = "month-abbr-day-year"
809    }
810}
811
812/// A title component.
813#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
814#[cfg_attr(feature = "schema", derive(JsonSchema))]
815#[serde(rename_all = "kebab-case", deny_unknown_fields)]
816pub struct TemplateTitle {
817    pub title: TitleType,
818    #[serde(skip_serializing_if = "Option::is_none")]
819    pub form: Option<TitleForm>,
820    /// When true, suppress this title component unless the reference needs
821    /// disambiguation (i.e. multiple works by the same author appear in the
822    /// document). Used by author-class styles (e.g. MLA) where the title
823    /// appears in citations only to resolve same-author ambiguity.
824    #[serde(skip_serializing_if = "Option::is_none")]
825    pub disambiguate_only: Option<bool>,
826    #[serde(flatten, default)]
827    pub rendering: Rendering,
828    /// Structured link options (DOI, URL).
829    #[serde(skip_serializing_if = "Option::is_none")]
830    pub links: Option<crate::options::LinksConfig>,
831
832    /// Custom user-defined fields for extensions.
833    #[serde(skip_serializing_if = "Option::is_none")]
834    pub custom: Option<HashMap<String, serde_json::Value>>,
835}
836
837/// Types of titles.
838#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
839#[cfg_attr(feature = "schema", derive(JsonSchema))]
840#[serde(rename_all = "kebab-case")]
841#[non_exhaustive]
842pub enum TitleType {
843    /// The primary title of the cited work.
844    #[default]
845    Primary,
846    /// Title of a book/monograph containing the cited work.
847    ParentMonograph,
848    /// Title of a periodical/serial containing the cited work.
849    ParentSerial,
850}
851
852/// Title rendering forms.
853#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
854#[cfg_attr(feature = "schema", derive(JsonSchema))]
855#[serde(rename_all = "kebab-case")]
856pub enum TitleForm {
857    Short,
858    #[default]
859    Long,
860}
861
862/// A number component (volume, issue, pages, etc.).
863#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
864#[cfg_attr(feature = "schema", derive(JsonSchema))]
865#[serde(rename_all = "kebab-case", deny_unknown_fields)]
866pub struct TemplateNumber {
867    pub number: NumberVariable,
868    #[serde(skip_serializing_if = "Option::is_none")]
869    pub form: Option<NumberForm>,
870    #[serde(skip_serializing_if = "Option::is_none")]
871    pub label_form: Option<LabelForm>,
872    /// When `true`, show this pages component even when a locator is present in a note-style citation.
873    /// By default, pages are suppressed in note-style citations when a locator is present.
874    #[serde(skip_serializing_if = "Option::is_none")]
875    pub show_with_locator: Option<bool>,
876    #[serde(flatten)]
877    pub rendering: Rendering,
878    /// Structured link options (DOI, URL).
879    #[serde(skip_serializing_if = "Option::is_none")]
880    pub links: Option<crate::options::LinksConfig>,
881    /// Explicit grammatical gender override for number/ordinal agreement.
882    #[serde(skip_serializing_if = "Option::is_none")]
883    pub gender: Option<GrammaticalGender>,
884
885    /// Custom user-defined fields for extensions.
886    #[serde(skip_serializing_if = "Option::is_none")]
887    pub custom: Option<HashMap<String, serde_json::Value>>,
888}
889
890/// Number variables.
891///
892/// Use `number:` when the value is treated as a number by the style:
893/// numeric labels, numeric-specific formatting, ordinals, roman numerals, or
894/// locator-aware punctuation. Use `variable:` instead when the field should be
895/// passed through as plain text without number formatting semantics.
896#[derive(Debug, Default, Clone)]
897#[non_exhaustive]
898pub enum NumberVariable {
899    #[default]
900    Volume,
901    Issue,
902    Pages,
903    Edition,
904    ChapterNumber,
905    CollectionNumber,
906    NumberOfPages,
907    NumberOfVolumes,
908    CitationNumber,
909    CitationLabel,
910    Number,
911    DocketNumber,
912    PatentNumber,
913    StandardNumber,
914    ReportNumber,
915    PartNumber,
916    SupplementNumber,
917    PrintingNumber,
918    /// A custom numbering variable rendered from an arbitrary numbering kind.
919    Custom(String),
920}
921
922impl NumberVariable {
923    /// Return the canonical kebab-case key for this numeric variable.
924    #[must_use]
925    pub fn as_key(&self) -> Cow<'_, str> {
926        match self {
927            Self::Volume => Cow::Borrowed("volume"),
928            Self::Issue => Cow::Borrowed("issue"),
929            Self::Pages => Cow::Borrowed("pages"),
930            Self::Edition => Cow::Borrowed("edition"),
931            Self::ChapterNumber => Cow::Borrowed("chapter-number"),
932            Self::CollectionNumber => Cow::Borrowed("collection-number"),
933            Self::NumberOfPages => Cow::Borrowed("number-of-pages"),
934            Self::NumberOfVolumes => Cow::Borrowed("number-of-volumes"),
935            Self::CitationNumber => Cow::Borrowed("citation-number"),
936            Self::CitationLabel => Cow::Borrowed("citation-label"),
937            Self::Number => Cow::Borrowed("number"),
938            Self::DocketNumber => Cow::Borrowed("docket-number"),
939            Self::PatentNumber => Cow::Borrowed("patent-number"),
940            Self::StandardNumber => Cow::Borrowed("standard-number"),
941            Self::ReportNumber => Cow::Borrowed("report-number"),
942            Self::PartNumber => Cow::Borrowed("part-number"),
943            Self::SupplementNumber => Cow::Borrowed("supplement-number"),
944            Self::PrintingNumber => Cow::Borrowed("printing-number"),
945            Self::Custom(value) => normalize_kind_key(value)
946                .map(Cow::Owned)
947                .unwrap_or_else(|| Cow::Borrowed(value.as_str())),
948        }
949    }
950
951    fn from_key(value: &str) -> Result<Self, String> {
952        let canonical = normalize_kind_key(value)
953            .ok_or_else(|| "number variable must not be empty".to_string())?;
954        Ok(match canonical.as_str() {
955            "volume" => Self::Volume,
956            "issue" => Self::Issue,
957            "pages" => Self::Pages,
958            "edition" => Self::Edition,
959            "chapter-number" => Self::ChapterNumber,
960            "collection-number" => Self::CollectionNumber,
961            "number-of-pages" => Self::NumberOfPages,
962            "number-of-volumes" => Self::NumberOfVolumes,
963            "citation-number" => Self::CitationNumber,
964            "citation-label" => Self::CitationLabel,
965            "number" => Self::Number,
966            "docket-number" => Self::DocketNumber,
967            "patent-number" => Self::PatentNumber,
968            "standard-number" => Self::StandardNumber,
969            "report-number" => Self::ReportNumber,
970            "part-number" => Self::PartNumber,
971            "supplement-number" => Self::SupplementNumber,
972            "printing-number" => Self::PrintingNumber,
973            _ => Self::Custom(canonical),
974        })
975    }
976}
977
978impl PartialEq for NumberVariable {
979    fn eq(&self, other: &Self) -> bool {
980        self.as_key().as_ref() == other.as_key().as_ref()
981    }
982}
983
984impl Eq for NumberVariable {}
985
986impl Hash for NumberVariable {
987    fn hash<H: Hasher>(&self, state: &mut H) {
988        self.as_key().as_ref().hash(state);
989    }
990}
991
992impl Serialize for NumberVariable {
993    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
994    where
995        S: Serializer,
996    {
997        serializer.serialize_str(self.as_key().as_ref())
998    }
999}
1000
1001impl<'de> Deserialize<'de> for NumberVariable {
1002    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1003    where
1004        D: Deserializer<'de>,
1005    {
1006        let value = String::deserialize(deserializer)?;
1007        Self::from_key(&value).map_err(serde::de::Error::custom)
1008    }
1009}
1010
1011#[cfg(feature = "schema")]
1012impl JsonSchema for NumberVariable {
1013    fn schema_name() -> std::borrow::Cow<'static, str> {
1014        "NumberVariable".into()
1015    }
1016
1017    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1018        schemars::json_schema!({
1019            "type": "string",
1020            "description": "Known number variable keyword or custom kebab-case identifier."
1021        })
1022    }
1023}
1024
1025/// Number rendering forms.
1026#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1027#[cfg_attr(feature = "schema", derive(JsonSchema))]
1028#[serde(rename_all = "lowercase")]
1029pub enum NumberForm {
1030    #[default]
1031    Numeric,
1032    Ordinal,
1033    Roman,
1034}
1035
1036fn normalize_kind_key(value: &str) -> Option<String> {
1037    let mut normalized = String::new();
1038    let mut pending_dash = false;
1039
1040    for ch in value.trim().chars() {
1041        if ch.is_ascii_alphanumeric() {
1042            if pending_dash && !normalized.is_empty() {
1043                normalized.push('-');
1044            }
1045            normalized.push(ch.to_ascii_lowercase());
1046            pending_dash = false;
1047        } else if !normalized.is_empty() {
1048            pending_dash = true;
1049        }
1050    }
1051
1052    if normalized.is_empty() {
1053        None
1054    } else {
1055        Some(normalized)
1056    }
1057}
1058
1059/// Label rendering forms.
1060#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1061#[cfg_attr(feature = "schema", derive(JsonSchema))]
1062#[serde(rename_all = "kebab-case")]
1063pub enum LabelForm {
1064    Long,
1065    #[default]
1066    Short,
1067    Symbol,
1068}
1069
1070/// A simple variable component (DOI, ISBN, URL, etc.).
1071#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1072#[cfg_attr(feature = "schema", derive(JsonSchema))]
1073#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1074pub struct TemplateVariable {
1075    pub variable: SimpleVariable,
1076    #[serde(flatten)]
1077    pub rendering: Rendering,
1078    /// Structured link options (DOI, URL).
1079    #[serde(skip_serializing_if = "Option::is_none")]
1080    pub links: Option<crate::options::LinksConfig>,
1081
1082    /// Custom user-defined fields for extensions.
1083    #[serde(skip_serializing_if = "Option::is_none")]
1084    pub custom: Option<HashMap<String, serde_json::Value>>,
1085}
1086
1087/// Simple string variables.
1088///
1089/// Use `variable:` for string passthrough fields, even when the field name is
1090/// also present in [`NumberVariable`]. For example, `variable: volume` keeps the
1091/// source value as plain text, while `number: volume` opts into numeric
1092/// formatting behavior.
1093#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1094#[cfg_attr(feature = "schema", derive(JsonSchema))]
1095#[serde(rename_all = "kebab-case")]
1096#[non_exhaustive]
1097pub enum SimpleVariable {
1098    #[default]
1099    Doi,
1100    Isbn,
1101    Issn,
1102    Url,
1103    Pmid,
1104    Pmcid,
1105    Abstract,
1106    Note,
1107    Annote,
1108    Keyword,
1109    Genre,
1110    Medium,
1111    Source,
1112    Status,
1113    Archive,
1114    ArchiveLocation,
1115    ArchiveName,
1116    ArchivePlace,
1117    ArchiveCollection,
1118    ArchiveCollectionId,
1119    ArchiveSeries,
1120    ArchiveBox,
1121    ArchiveFolder,
1122    ArchiveItem,
1123    ArchiveUrl,
1124    EprintId,
1125    EprintServer,
1126    EprintClass,
1127    Publisher,
1128    PublisherPlace,
1129    OriginalPublisher,
1130    OriginalPublisherPlace,
1131    EventPlace,
1132    Dimensions,
1133    Scale,
1134    Version,
1135    Locator,
1136    ContainerTitleShort,
1137    Authority,
1138    Code,
1139    Reporter,
1140    Page,
1141    Section,
1142    Volume,
1143    Number,
1144    DocketNumber,
1145    PatentNumber,
1146    StandardNumber,
1147    ReportNumber,
1148    AdsBibcode,
1149}
1150
1151/// A term component for rendering locale-specific text.
1152#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1153#[cfg_attr(feature = "schema", derive(JsonSchema))]
1154#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1155pub struct TemplateTerm {
1156    /// Which term to render.
1157    pub term: GeneralTerm,
1158    /// Form: long (default), short, or symbol.
1159    #[serde(skip_serializing_if = "Option::is_none")]
1160    pub form: Option<TermForm>,
1161    /// Explicit grammatical gender override for term selection.
1162    #[serde(skip_serializing_if = "Option::is_none")]
1163    pub gender: Option<GrammaticalGender>,
1164    #[serde(flatten, default)]
1165    pub rendering: Rendering,
1166
1167    /// Custom user-defined fields for extensions.
1168    #[serde(skip_serializing_if = "Option::is_none")]
1169    pub custom: Option<HashMap<String, serde_json::Value>>,
1170}
1171
1172/// A group component for grouping multiple components with a delimiter,
1173/// matching CSL 1.0 `<group>` semantics.
1174#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1175#[cfg_attr(feature = "schema", derive(JsonSchema))]
1176#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1177pub struct TemplateGroup {
1178    pub group: Vec<TemplateComponent>,
1179    #[serde(skip_serializing_if = "Option::is_none")]
1180    pub delimiter: Option<DelimiterPunctuation>,
1181    #[serde(flatten, default)]
1182    pub rendering: Rendering,
1183
1184    /// Custom user-defined fields for extensions.
1185    #[serde(skip_serializing_if = "Option::is_none")]
1186    pub custom: Option<HashMap<String, serde_json::Value>>,
1187}
1188
1189/// Delimiter punctuation options.
1190#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)]
1191#[serde(rename_all = "kebab-case")]
1192pub enum DelimiterPunctuation {
1193    #[default]
1194    Comma,
1195    Semicolon,
1196    Period,
1197    Colon,
1198    Ampersand,
1199    VerticalLine,
1200    Slash,
1201    Hyphen,
1202    Space,
1203    None,
1204    /// Custom delimiter string (e.g., ": ").
1205    #[serde(untagged)]
1206    Custom(String),
1207}
1208
1209#[cfg(feature = "schema")]
1210impl JsonSchema for DelimiterPunctuation {
1211    fn schema_name() -> std::borrow::Cow<'static, str> {
1212        "DelimiterPunctuation".into()
1213    }
1214
1215    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1216        schemars::json_schema!({"type": "string", "description": "Delimiter punctuation options."})
1217    }
1218}
1219
1220impl DelimiterPunctuation {
1221    /// Convert this delimiter to a string with trailing space.
1222    ///
1223    /// Returns the punctuation followed by a space, except for Space (single space) and None (empty string).
1224    pub fn to_string_with_space(&self) -> String {
1225        match self {
1226            Self::Comma => ", ".to_string(),
1227            Self::Semicolon => "; ".to_string(),
1228            Self::Period => ". ".to_string(),
1229            Self::Colon => ": ".to_string(),
1230            Self::Ampersand => " & ".to_string(),
1231            Self::VerticalLine => " | ".to_string(),
1232            Self::Slash => "/".to_string(),
1233            Self::Hyphen => "-".to_string(),
1234            Self::Space => " ".to_string(),
1235            Self::None => "".to_string(),
1236            Self::Custom(s) => s.clone(),
1237        }
1238    }
1239
1240    /// Parse a delimiter from a CSL 1.0 delimiter string.
1241    ///
1242    /// Handles common patterns like ", ", ": ", etc.
1243    /// Returns the Custom variant for unrecognized delimiters.
1244    pub fn from_csl_string(s: &str) -> Self {
1245        if s == " " {
1246            return Self::Space;
1247        }
1248
1249        let trimmed = s.trim();
1250        if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
1251            return Self::None;
1252        }
1253
1254        match trimmed {
1255            "," => Self::Comma,
1256            ";" => Self::Semicolon,
1257            "." => Self::Period,
1258            ":" => Self::Colon,
1259            "&" => Self::Ampersand,
1260            "|" => Self::VerticalLine,
1261            "/" => Self::Slash,
1262            "-" => Self::Hyphen,
1263            _ => Self::Custom(s.to_string()),
1264        }
1265    }
1266}
1267
1268#[cfg(test)]
1269#[allow(
1270    clippy::unwrap_used,
1271    clippy::expect_used,
1272    clippy::panic,
1273    clippy::indexing_slicing,
1274    clippy::todo,
1275    clippy::unimplemented,
1276    clippy::unreachable,
1277    clippy::get_unwrap,
1278    reason = "Panicking is acceptable and often desired in tests."
1279)]
1280mod tests {
1281    use super::*;
1282
1283    #[test]
1284    fn test_contributor_deserialization() {
1285        let yaml = r#"
1286contributor: author
1287form: long
1288"#;
1289        let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1290        assert_eq!(comp.contributor, ContributorRole::Author);
1291        assert_eq!(comp.form, ContributorForm::Long);
1292    }
1293
1294    #[test]
1295    fn test_template_component_untagged() {
1296        let yaml = r#"
1297- contributor: author
1298  form: short
1299- date: issued
1300  form: year
1301- title: primary
1302"#;
1303        let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1304        assert_eq!(components.len(), 3);
1305
1306        match &components[0] {
1307            TemplateComponent::Contributor(c) => {
1308                assert_eq!(c.contributor, ContributorRole::Author);
1309            }
1310            _ => panic!("Expected Contributor"),
1311        }
1312
1313        match &components[1] {
1314            TemplateComponent::Date(d) => {
1315                assert_eq!(d.date, DateVariable::Issued);
1316            }
1317            _ => panic!("Expected Date"),
1318        }
1319    }
1320
1321    #[test]
1322    fn test_flattened_rendering() {
1323        // Test that rendering options can be specified directly on the component
1324        let yaml = r#"
1325- title: parent-monograph
1326  prefix: "In "
1327  emph: true
1328- date: issued
1329  form: year
1330  wrap: parentheses
1331"#;
1332        let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1333        assert_eq!(components.len(), 2);
1334
1335        match &components[0] {
1336            TemplateComponent::Title(t) => {
1337                assert_eq!(t.rendering.prefix, Some("In ".to_string()));
1338                assert_eq!(t.rendering.emph, Some(true));
1339            }
1340            _ => panic!("Expected Title"),
1341        }
1342
1343        match &components[1] {
1344            TemplateComponent::Date(d) => {
1345                assert_eq!(
1346                    d.rendering.wrap,
1347                    Some(WrapConfig {
1348                        punctuation: WrapPunctuation::Parentheses,
1349                        inner_prefix: None,
1350                        inner_suffix: None,
1351                    })
1352                );
1353            }
1354            _ => panic!("Expected Date"),
1355        }
1356    }
1357
1358    #[test]
1359    fn test_number_variable_custom_normalizes_manual_construction() {
1360        let number = NumberVariable::Custom("Reel Label".to_string());
1361
1362        assert_eq!(number.as_key(), "reel-label");
1363        assert_eq!(
1364            number,
1365            serde_yaml::from_str::<NumberVariable>("reel-label")
1366                .expect("custom number variable should parse")
1367        );
1368        assert_eq!(
1369            serde_json::to_string(&number).expect("custom number variable should serialize"),
1370            "\"reel-label\""
1371        );
1372    }
1373
1374    #[test]
1375    fn test_contributor_with_wrap() {
1376        let yaml = r#"
1377contributor: publisher
1378form: short
1379wrap: parentheses
1380"#;
1381        let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1382        assert_eq!(comp.contributor, ContributorRole::Publisher);
1383        assert_eq!(
1384            comp.rendering.wrap,
1385            Some(WrapConfig {
1386                punctuation: WrapPunctuation::Parentheses,
1387                inner_prefix: None,
1388                inner_suffix: None,
1389            })
1390        );
1391    }
1392
1393    #[test]
1394    fn test_variable_deserialization() {
1395        // Test that `variable: publisher` parses as Variable, not Number
1396        let yaml = "variable: publisher\n";
1397        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1398        match comp {
1399            TemplateComponent::Variable(v) => {
1400                assert_eq!(v.variable, SimpleVariable::Publisher);
1401            }
1402            _ => panic!("Expected Variable(Publisher), got {:?}", comp),
1403        }
1404    }
1405
1406    #[test]
1407    fn test_variable_array_parsing() {
1408        let yaml = r#"
1409- variable: doi
1410  prefix: "https://doi.org/"
1411- variable: publisher
1412"#;
1413        let comps: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1414        assert_eq!(comps.len(), 2);
1415        match &comps[0] {
1416            TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Doi),
1417            _ => panic!("Expected Variable for doi, got {:?}", comps[0]),
1418        }
1419        match &comps[1] {
1420            TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Publisher),
1421            _ => panic!("Expected Variable for publisher, got {:?}", comps[1]),
1422        }
1423    }
1424
1425    #[test]
1426    fn test_type_selector_default_only_matches_default_context() {
1427        let selector = TypeSelector::Single("default".to_string());
1428        assert!(selector.matches("default"));
1429        assert!(!selector.matches("article-journal"));
1430
1431        let mixed = TypeSelector::Multiple(vec!["default".to_string(), "chapter".to_string()]);
1432        assert!(mixed.matches("default"));
1433        assert!(mixed.matches("chapter"));
1434        assert!(!mixed.matches("book"));
1435    }
1436
1437    #[test]
1438    fn test_template_component_selector_matches_nested_partial_group() {
1439        let component: TemplateComponent = serde_yaml::from_str(
1440            r#"
1441delimiter: ""
1442group:
1443- number: citation-number
1444  wrap:
1445    punctuation: brackets
1446- contributor: author
1447  form: long
1448"#,
1449        )
1450        .unwrap();
1451        let selector = TemplateComponentSelector {
1452            fields: BTreeMap::from([(
1453                "group".to_string(),
1454                serde_json::json!([
1455                    { "number": "citation-number" },
1456                    { "contributor": "author" }
1457                ]),
1458            )]),
1459        };
1460
1461        assert!(selector.matches(&component));
1462    }
1463
1464    #[test]
1465    fn test_delimiter_from_csl_string_normalizes_none_and_trimmed_values() {
1466        assert_eq!(
1467            DelimiterPunctuation::from_csl_string("none"),
1468            DelimiterPunctuation::None
1469        );
1470        assert_eq!(
1471            DelimiterPunctuation::from_csl_string(" none "),
1472            DelimiterPunctuation::None
1473        );
1474        assert_eq!(
1475            DelimiterPunctuation::from_csl_string(" "),
1476            DelimiterPunctuation::Space
1477        );
1478        assert_eq!(
1479            DelimiterPunctuation::from_csl_string(" : "),
1480            DelimiterPunctuation::Colon
1481        );
1482    }
1483}