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    CitationLabel,
923    Number,
924    DocketNumber,
925    PatentNumber,
926    StandardNumber,
927    ReportNumber,
928    PartNumber,
929    SupplementNumber,
930    PrintingNumber,
931    /// A custom numbering variable rendered from an arbitrary numbering kind.
932    Custom(String),
933}
934
935impl NumberVariable {
936    /// Return the canonical kebab-case key for this numeric variable.
937    #[must_use]
938    pub fn as_key(&self) -> Cow<'_, str> {
939        match self {
940            Self::Volume => Cow::Borrowed("volume"),
941            Self::Issue => Cow::Borrowed("issue"),
942            Self::Pages => Cow::Borrowed("pages"),
943            Self::Edition => Cow::Borrowed("edition"),
944            Self::ChapterNumber => Cow::Borrowed("chapter-number"),
945            Self::CollectionNumber => Cow::Borrowed("collection-number"),
946            Self::NumberOfPages => Cow::Borrowed("number-of-pages"),
947            Self::NumberOfVolumes => Cow::Borrowed("number-of-volumes"),
948            Self::CitationNumber => Cow::Borrowed("citation-number"),
949            Self::CitationLabel => Cow::Borrowed("citation-label"),
950            Self::Number => Cow::Borrowed("number"),
951            Self::DocketNumber => Cow::Borrowed("docket-number"),
952            Self::PatentNumber => Cow::Borrowed("patent-number"),
953            Self::StandardNumber => Cow::Borrowed("standard-number"),
954            Self::ReportNumber => Cow::Borrowed("report-number"),
955            Self::PartNumber => Cow::Borrowed("part-number"),
956            Self::SupplementNumber => Cow::Borrowed("supplement-number"),
957            Self::PrintingNumber => Cow::Borrowed("printing-number"),
958            Self::Custom(value) => normalize_kind_key(value)
959                .map(Cow::Owned)
960                .unwrap_or_else(|| Cow::Borrowed(value.as_str())),
961        }
962    }
963
964    fn from_key(value: &str) -> Result<Self, String> {
965        let canonical = normalize_kind_key(value)
966            .ok_or_else(|| "number variable must not be empty".to_string())?;
967        Ok(match canonical.as_str() {
968            "volume" => Self::Volume,
969            "issue" => Self::Issue,
970            "pages" => Self::Pages,
971            "edition" => Self::Edition,
972            "chapter-number" => Self::ChapterNumber,
973            "collection-number" => Self::CollectionNumber,
974            "number-of-pages" => Self::NumberOfPages,
975            "number-of-volumes" => Self::NumberOfVolumes,
976            "citation-number" => Self::CitationNumber,
977            "citation-label" => Self::CitationLabel,
978            "number" => Self::Number,
979            "docket-number" => Self::DocketNumber,
980            "patent-number" => Self::PatentNumber,
981            "standard-number" => Self::StandardNumber,
982            "report-number" => Self::ReportNumber,
983            "part-number" => Self::PartNumber,
984            "supplement-number" => Self::SupplementNumber,
985            "printing-number" => Self::PrintingNumber,
986            _ => Self::Custom(canonical),
987        })
988    }
989}
990
991impl PartialEq for NumberVariable {
992    fn eq(&self, other: &Self) -> bool {
993        self.as_key().as_ref() == other.as_key().as_ref()
994    }
995}
996
997impl Eq for NumberVariable {}
998
999impl Hash for NumberVariable {
1000    fn hash<H: Hasher>(&self, state: &mut H) {
1001        self.as_key().as_ref().hash(state);
1002    }
1003}
1004
1005impl Serialize for NumberVariable {
1006    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1007    where
1008        S: Serializer,
1009    {
1010        serializer.serialize_str(self.as_key().as_ref())
1011    }
1012}
1013
1014impl<'de> Deserialize<'de> for NumberVariable {
1015    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1016    where
1017        D: Deserializer<'de>,
1018    {
1019        let value = String::deserialize(deserializer)?;
1020        Self::from_key(&value).map_err(serde::de::Error::custom)
1021    }
1022}
1023
1024#[cfg(feature = "schema")]
1025impl JsonSchema for NumberVariable {
1026    fn schema_name() -> std::borrow::Cow<'static, str> {
1027        "NumberVariable".into()
1028    }
1029
1030    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1031        schemars::json_schema!({
1032            "type": "string",
1033            "description": "Known number variable keyword or custom kebab-case identifier."
1034        })
1035    }
1036}
1037
1038/// Number rendering forms.
1039#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1040#[cfg_attr(feature = "schema", derive(JsonSchema))]
1041#[serde(rename_all = "lowercase")]
1042pub enum NumberForm {
1043    #[default]
1044    Numeric,
1045    Ordinal,
1046    Roman,
1047}
1048
1049fn normalize_kind_key(value: &str) -> Option<String> {
1050    let mut normalized = String::new();
1051    let mut pending_dash = false;
1052
1053    for ch in value.trim().chars() {
1054        if ch.is_ascii_alphanumeric() {
1055            if pending_dash && !normalized.is_empty() {
1056                normalized.push('-');
1057            }
1058            normalized.push(ch.to_ascii_lowercase());
1059            pending_dash = false;
1060        } else if !normalized.is_empty() {
1061            pending_dash = true;
1062        }
1063    }
1064
1065    if normalized.is_empty() {
1066        None
1067    } else {
1068        Some(normalized)
1069    }
1070}
1071
1072/// Label rendering forms.
1073#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1074#[cfg_attr(feature = "schema", derive(JsonSchema))]
1075#[serde(rename_all = "kebab-case")]
1076pub enum LabelForm {
1077    Long,
1078    #[default]
1079    Short,
1080    Symbol,
1081}
1082
1083/// A simple variable component (DOI, ISBN, URL, etc.).
1084#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1085#[cfg_attr(feature = "schema", derive(JsonSchema))]
1086#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1087pub struct TemplateVariable {
1088    pub variable: SimpleVariable,
1089    #[serde(flatten)]
1090    pub rendering: Rendering,
1091    /// Structured link options (DOI, URL).
1092    #[serde(skip_serializing_if = "Option::is_none")]
1093    pub links: Option<crate::options::LinksConfig>,
1094
1095    /// Custom user-defined fields for extensions.
1096    #[serde(skip_serializing_if = "Option::is_none")]
1097    pub custom: Option<HashMap<String, serde_json::Value>>,
1098}
1099
1100/// Simple string variables.
1101///
1102/// Use `variable:` for string passthrough fields, even when the field name is
1103/// also present in [`NumberVariable`]. For example, `variable: volume` keeps the
1104/// source value as plain text, while `number: volume` opts into numeric
1105/// formatting behavior.
1106#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1107#[cfg_attr(feature = "schema", derive(JsonSchema))]
1108#[serde(rename_all = "kebab-case")]
1109#[non_exhaustive]
1110pub enum SimpleVariable {
1111    #[default]
1112    Doi,
1113    Isbn,
1114    Issn,
1115    Url,
1116    Pmid,
1117    Pmcid,
1118    Abstract,
1119    Note,
1120    Annote,
1121    Keyword,
1122    Genre,
1123    Medium,
1124    Source,
1125    Status,
1126    Archive,
1127    ArchiveLocation,
1128    ArchiveName,
1129    ArchivePlace,
1130    ArchiveCollection,
1131    ArchiveCollectionId,
1132    ArchiveSeries,
1133    ArchiveBox,
1134    ArchiveFolder,
1135    ArchiveItem,
1136    ArchiveUrl,
1137    EprintId,
1138    EprintServer,
1139    EprintClass,
1140    Publisher,
1141    PublisherPlace,
1142    OriginalPublisher,
1143    OriginalPublisherPlace,
1144    EventPlace,
1145    Dimensions,
1146    Scale,
1147    Version,
1148    Locator,
1149    ContainerTitleShort,
1150    Authority,
1151    Code,
1152    Reporter,
1153    Page,
1154    Section,
1155    Volume,
1156    Number,
1157    DocketNumber,
1158    PatentNumber,
1159    StandardNumber,
1160    ReportNumber,
1161    AdsBibcode,
1162}
1163
1164/// A term component for rendering locale-specific text.
1165#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1166#[cfg_attr(feature = "schema", derive(JsonSchema))]
1167#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1168pub struct TemplateTerm {
1169    /// Which term to render.
1170    pub term: GeneralTerm,
1171    /// Form: long (default), short, or symbol.
1172    #[serde(skip_serializing_if = "Option::is_none")]
1173    pub form: Option<TermForm>,
1174    /// Explicit grammatical gender override for term selection.
1175    #[serde(skip_serializing_if = "Option::is_none")]
1176    pub gender: Option<GrammaticalGender>,
1177    #[serde(flatten, default)]
1178    pub rendering: Rendering,
1179
1180    /// Custom user-defined fields for extensions.
1181    #[serde(skip_serializing_if = "Option::is_none")]
1182    pub custom: Option<HashMap<String, serde_json::Value>>,
1183}
1184
1185/// A group component for grouping multiple components with a delimiter,
1186/// matching CSL 1.0 `<group>` semantics.
1187#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1188#[cfg_attr(feature = "schema", derive(JsonSchema))]
1189#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1190pub struct TemplateGroup {
1191    pub group: Vec<TemplateComponent>,
1192    #[serde(skip_serializing_if = "Option::is_none")]
1193    pub delimiter: Option<DelimiterPunctuation>,
1194    #[serde(flatten, default)]
1195    pub rendering: Rendering,
1196
1197    /// Custom user-defined fields for extensions.
1198    #[serde(skip_serializing_if = "Option::is_none")]
1199    pub custom: Option<HashMap<String, serde_json::Value>>,
1200}
1201
1202/// Delimiter punctuation options.
1203#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)]
1204#[serde(rename_all = "kebab-case")]
1205pub enum DelimiterPunctuation {
1206    #[default]
1207    Comma,
1208    Semicolon,
1209    Period,
1210    Colon,
1211    Ampersand,
1212    VerticalLine,
1213    Slash,
1214    Hyphen,
1215    Space,
1216    None,
1217    /// Custom delimiter string (e.g., ": ").
1218    #[serde(untagged)]
1219    Custom(String),
1220}
1221
1222#[cfg(feature = "schema")]
1223impl JsonSchema for DelimiterPunctuation {
1224    fn schema_name() -> std::borrow::Cow<'static, str> {
1225        "DelimiterPunctuation".into()
1226    }
1227
1228    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1229        schemars::json_schema!({"type": "string", "description": "Delimiter punctuation options."})
1230    }
1231}
1232
1233impl DelimiterPunctuation {
1234    /// Convert this delimiter to a string with trailing space.
1235    ///
1236    /// Returns the punctuation followed by a space, except for Space (single space) and None (empty string).
1237    pub fn to_string_with_space(&self) -> String {
1238        match self {
1239            Self::Comma => ", ".to_string(),
1240            Self::Semicolon => "; ".to_string(),
1241            Self::Period => ". ".to_string(),
1242            Self::Colon => ": ".to_string(),
1243            Self::Ampersand => " & ".to_string(),
1244            Self::VerticalLine => " | ".to_string(),
1245            Self::Slash => "/".to_string(),
1246            Self::Hyphen => "-".to_string(),
1247            Self::Space => " ".to_string(),
1248            Self::None => "".to_string(),
1249            Self::Custom(s) => s.clone(),
1250        }
1251    }
1252
1253    /// Parse a delimiter from a CSL 1.0 delimiter string.
1254    ///
1255    /// Handles common patterns like ", ", ": ", etc.
1256    /// Returns the Custom variant for unrecognized delimiters.
1257    pub fn from_csl_string(s: &str) -> Self {
1258        if s == " " {
1259            return Self::Space;
1260        }
1261
1262        let trimmed = s.trim();
1263        if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
1264            return Self::None;
1265        }
1266
1267        match trimmed {
1268            "," => Self::Comma,
1269            ";" => Self::Semicolon,
1270            "." => Self::Period,
1271            ":" => Self::Colon,
1272            "&" => Self::Ampersand,
1273            "|" => Self::VerticalLine,
1274            "/" => Self::Slash,
1275            "-" => Self::Hyphen,
1276            _ => Self::Custom(s.to_string()),
1277        }
1278    }
1279}
1280
1281#[cfg(test)]
1282#[allow(
1283    clippy::unwrap_used,
1284    clippy::expect_used,
1285    clippy::panic,
1286    clippy::indexing_slicing,
1287    clippy::todo,
1288    clippy::unimplemented,
1289    clippy::unreachable,
1290    clippy::get_unwrap,
1291    reason = "Panicking is acceptable and often desired in tests."
1292)]
1293mod tests {
1294    use super::*;
1295
1296    #[test]
1297    fn test_contributor_deserialization() {
1298        let yaml = r#"
1299contributor: author
1300form: long
1301"#;
1302        let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1303        assert_eq!(comp.contributor, ContributorRole::Author);
1304        assert_eq!(comp.form, ContributorForm::Long);
1305    }
1306
1307    #[test]
1308    fn test_template_component_untagged() {
1309        let yaml = r#"
1310- contributor: author
1311  form: short
1312- date: issued
1313  form: year
1314- title: primary
1315"#;
1316        let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1317        assert_eq!(components.len(), 3);
1318
1319        match &components[0] {
1320            TemplateComponent::Contributor(c) => {
1321                assert_eq!(c.contributor, ContributorRole::Author);
1322            }
1323            _ => panic!("Expected Contributor"),
1324        }
1325
1326        match &components[1] {
1327            TemplateComponent::Date(d) => {
1328                assert_eq!(d.date, DateVariable::Issued);
1329            }
1330            _ => panic!("Expected Date"),
1331        }
1332    }
1333
1334    #[test]
1335    fn test_flattened_rendering() {
1336        // Test that rendering options can be specified directly on the component
1337        let yaml = r#"
1338- title: parent-monograph
1339  prefix: "In "
1340  emph: true
1341- date: issued
1342  form: year
1343  wrap: parentheses
1344"#;
1345        let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1346        assert_eq!(components.len(), 2);
1347
1348        match &components[0] {
1349            TemplateComponent::Title(t) => {
1350                assert_eq!(t.rendering.prefix, Some("In ".to_string()));
1351                assert_eq!(t.rendering.emph, Some(true));
1352            }
1353            _ => panic!("Expected Title"),
1354        }
1355
1356        match &components[1] {
1357            TemplateComponent::Date(d) => {
1358                assert_eq!(
1359                    d.rendering.wrap,
1360                    Some(WrapConfig {
1361                        punctuation: WrapPunctuation::Parentheses,
1362                        inner_prefix: None,
1363                        inner_suffix: None,
1364                    })
1365                );
1366            }
1367            _ => panic!("Expected Date"),
1368        }
1369    }
1370
1371    #[test]
1372    fn test_number_variable_custom_normalizes_manual_construction() {
1373        let number = NumberVariable::Custom("Reel Label".to_string());
1374
1375        assert_eq!(number.as_key(), "reel-label");
1376        assert_eq!(
1377            number,
1378            serde_yaml::from_str::<NumberVariable>("reel-label")
1379                .expect("custom number variable should parse")
1380        );
1381        assert_eq!(
1382            serde_json::to_string(&number).expect("custom number variable should serialize"),
1383            "\"reel-label\""
1384        );
1385    }
1386
1387    #[test]
1388    fn test_contributor_with_wrap() {
1389        let yaml = r#"
1390contributor: publisher
1391form: short
1392wrap: parentheses
1393"#;
1394        let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1395        assert_eq!(comp.contributor, ContributorRole::Publisher);
1396        assert_eq!(
1397            comp.rendering.wrap,
1398            Some(WrapConfig {
1399                punctuation: WrapPunctuation::Parentheses,
1400                inner_prefix: None,
1401                inner_suffix: None,
1402            })
1403        );
1404    }
1405
1406    #[test]
1407    fn test_variable_deserialization() {
1408        // Test that `variable: publisher` parses as Variable, not Number
1409        let yaml = "variable: publisher\n";
1410        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1411        match comp {
1412            TemplateComponent::Variable(v) => {
1413                assert_eq!(v.variable, SimpleVariable::Publisher);
1414            }
1415            _ => panic!("Expected Variable(Publisher), got {:?}", comp),
1416        }
1417    }
1418
1419    #[test]
1420    fn test_variable_array_parsing() {
1421        let yaml = r#"
1422- variable: doi
1423  prefix: "https://doi.org/"
1424- variable: publisher
1425"#;
1426        let comps: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1427        assert_eq!(comps.len(), 2);
1428        match &comps[0] {
1429            TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Doi),
1430            _ => panic!("Expected Variable for doi, got {:?}", comps[0]),
1431        }
1432        match &comps[1] {
1433            TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Publisher),
1434            _ => panic!("Expected Variable for publisher, got {:?}", comps[1]),
1435        }
1436    }
1437
1438    #[test]
1439    fn test_type_selector_default_only_matches_default_context() {
1440        let selector = TypeSelector::Single("default".to_string());
1441        assert!(selector.matches("default"));
1442        assert!(!selector.matches("article-journal"));
1443
1444        let mixed = TypeSelector::Multiple(vec!["default".to_string(), "chapter".to_string()]);
1445        assert!(mixed.matches("default"));
1446        assert!(mixed.matches("chapter"));
1447        assert!(!mixed.matches("book"));
1448    }
1449
1450    #[test]
1451    fn test_template_component_selector_matches_nested_partial_group() {
1452        let component: TemplateComponent = serde_yaml::from_str(
1453            r#"
1454delimiter: ""
1455group:
1456- number: citation-number
1457  wrap:
1458    punctuation: brackets
1459- contributor: author
1460  form: long
1461"#,
1462        )
1463        .unwrap();
1464        let selector = TemplateComponentSelector {
1465            fields: BTreeMap::from([(
1466                "group".to_string(),
1467                serde_json::json!([
1468                    { "number": "citation-number" },
1469                    { "contributor": "author" }
1470                ]),
1471            )]),
1472        };
1473
1474        assert!(selector.matches(&component));
1475    }
1476
1477    #[test]
1478    fn test_delimiter_from_csl_string_normalizes_none_and_trimmed_values() {
1479        assert_eq!(
1480            DelimiterPunctuation::from_csl_string("none"),
1481            DelimiterPunctuation::None
1482        );
1483        assert_eq!(
1484            DelimiterPunctuation::from_csl_string(" none "),
1485            DelimiterPunctuation::None
1486        );
1487        assert_eq!(
1488            DelimiterPunctuation::from_csl_string(" "),
1489            DelimiterPunctuation::Space
1490        );
1491        assert_eq!(
1492            DelimiterPunctuation::from_csl_string(" : "),
1493            DelimiterPunctuation::Colon
1494        );
1495    }
1496}