Skip to main content

citum_schema_style/
template.rs

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