Skip to main content

citum_schema_style/
template.rs

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