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