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}
675
676/// Term form for role labels.
677#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
678#[cfg_attr(feature = "schema", derive(JsonSchema))]
679#[serde(rename_all = "kebab-case")]
680pub enum RoleLabelForm {
681    #[default]
682    Short,
683    Long,
684}
685
686/// Label placement relative to contributor names.
687#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
688#[cfg_attr(feature = "schema", derive(JsonSchema))]
689#[serde(rename_all = "kebab-case")]
690pub enum LabelPlacement {
691    Prefix,
692    #[default]
693    Suffix,
694}
695
696/// A contributor component for rendering names.
697#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
698#[cfg_attr(feature = "schema", derive(JsonSchema))]
699#[serde(rename_all = "kebab-case", deny_unknown_fields)]
700pub struct TemplateContributor {
701    /// Which contributor role to render (author, editor, etc.).
702    pub contributor: ContributorRole,
703    /// How to display the contributor (long names, short, with label, etc.).
704    pub form: ContributorForm,
705    /// Optional role label configuration (e.g., "eds." for editors).
706    #[serde(skip_serializing_if = "Option::is_none")]
707    pub label: Option<RoleLabel>,
708    /// Override the global name order for this specific component.
709    /// Use to show editors as "Given Family" even when global setting is "Family, Given".
710    #[serde(skip_serializing_if = "Option::is_none")]
711    pub name_order: Option<NameOrder>,
712    /// Override the name form (e.g., initials, full, family-only) for this specific component.
713    #[serde(skip_serializing_if = "Option::is_none", rename = "name-form")]
714    pub name_form: Option<crate::options::contributors::NameForm>,
715    /// Custom delimiter between names (overrides global setting).
716    #[serde(skip_serializing_if = "Option::is_none")]
717    pub delimiter: Option<String>,
718    /// Delimiter between family and given name when inverted (overrides global setting).
719    #[serde(skip_serializing_if = "Option::is_none")]
720    pub sort_separator: Option<String>,
721    /// Shorten the list of names (et al. configuration).
722    #[serde(skip_serializing_if = "Option::is_none")]
723    pub shorten: Option<crate::options::ShortenListOptions>,
724    /// Override the conjunction between the last two names.
725    /// Use `none` for bibliography when citation uses `text` or `symbol`.
726    #[serde(skip_serializing_if = "Option::is_none")]
727    pub and: Option<crate::options::AndOptions>,
728    #[serde(flatten, default)]
729    pub rendering: Rendering,
730    /// Structured link options (DOI, URL).
731    #[serde(skip_serializing_if = "Option::is_none")]
732    pub links: Option<crate::options::LinksConfig>,
733    /// Explicit grammatical gender override for role-label agreement.
734    #[serde(skip_serializing_if = "Option::is_none")]
735    pub gender: Option<GrammaticalGender>,
736
737    /// Custom user-defined fields for extensions.
738    #[serde(skip_serializing_if = "Option::is_none")]
739    pub custom: Option<HashMap<String, serde_json::Value>>,
740}
741
742/// Name display order.
743#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
744#[cfg_attr(feature = "schema", derive(JsonSchema))]
745#[serde(rename_all = "kebab-case")]
746pub enum NameOrder {
747    /// Display as "Given Family" (e.g., "John Smith").
748    GivenFirst,
749    /// Display as "Family, Given" (e.g., "Smith, John").
750    #[default]
751    FamilyFirst,
752    /// First contributor inverted ("Family, Given"); subsequent contributors given-first.
753    FamilyFirstOnly,
754}
755
756/// How to render contributor names.
757#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
758#[cfg_attr(feature = "schema", derive(JsonSchema))]
759#[serde(rename_all = "kebab-case")]
760pub enum ContributorForm {
761    #[default]
762    Long,
763    Short,
764    FamilyOnly,
765    Verb,
766    VerbShort,
767}
768
769crate::str_enum! {
770    /// Contributor roles.
771    #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
772    pub enum ContributorRole {
773        #[default] Author = "author",
774        Chair = "chair",
775        Editor = "editor",
776        Translator = "translator",
777        Director = "director",
778        Publisher = "publisher",
779        Recipient = "recipient",
780        Interviewer = "interviewer",
781        Interviewee = "interviewee",
782        Guest = "guest",
783        Inventor = "inventor",
784        Counsel = "counsel",
785        Composer = "composer",
786        CollectionEditor = "collection-editor",
787        ContainerAuthor = "container-author",
788        EditorialDirector = "editorial-director",
789        TextualEditor = "textual-editor",
790        Illustrator = "illustrator",
791        OriginalAuthor = "original-author",
792        ReviewedAuthor = "reviewed-author"
793    }
794}
795
796/// A date component for rendering dates.
797#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
798#[cfg_attr(feature = "schema", derive(JsonSchema))]
799#[serde(rename_all = "kebab-case", deny_unknown_fields)]
800pub struct TemplateDate {
801    pub date: DateVariable,
802    pub form: DateForm,
803    /// Fallback components if the primary date is missing.
804    #[serde(skip_serializing_if = "Option::is_none")]
805    pub fallback: Option<Vec<TemplateComponent>>,
806    #[serde(flatten, default)]
807    pub rendering: Rendering,
808    /// Structured link options (DOI, URL).
809    #[serde(skip_serializing_if = "Option::is_none")]
810    pub links: Option<crate::options::LinksConfig>,
811
812    /// Custom user-defined fields for extensions.
813    #[serde(skip_serializing_if = "Option::is_none")]
814    pub custom: Option<HashMap<String, serde_json::Value>>,
815}
816
817/// Date variables.
818#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
819#[cfg_attr(feature = "schema", derive(JsonSchema))]
820#[serde(rename_all = "kebab-case")]
821pub enum DateVariable {
822    #[default]
823    Issued,
824    Accessed,
825    OriginalPublished,
826    Submitted,
827    EventDate,
828}
829
830crate::str_enum! {
831    /// Date rendering forms.
832    #[derive(Debug, Default, Clone, PartialEq)]
833    pub enum DateForm {
834        #[default]
835        Year = "year",
836        YearMonth = "year-month",
837        Full = "full",
838        MonthDay = "month-day",
839        YearMonthDay = "year-month-day",
840        DayMonthAbbrYear = "day-month-abbr-year",
841        /// Abbreviated month + day + year in US order: "Jan 15, 2024".
842        MonthAbbrDayYear = "month-abbr-day-year"
843    }
844}
845
846/// A title component.
847#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
848#[cfg_attr(feature = "schema", derive(JsonSchema))]
849#[serde(rename_all = "kebab-case", deny_unknown_fields)]
850pub struct TemplateTitle {
851    pub title: TitleType,
852    #[serde(skip_serializing_if = "Option::is_none")]
853    pub form: Option<TitleForm>,
854    /// When true, suppress this title component unless the reference needs
855    /// disambiguation (i.e. multiple works by the same author appear in the
856    /// document). Used by author-class styles (e.g. MLA) where the title
857    /// appears in citations only to resolve same-author ambiguity.
858    #[serde(skip_serializing_if = "Option::is_none")]
859    pub disambiguate_only: Option<bool>,
860    #[serde(flatten, default)]
861    pub rendering: Rendering,
862    /// Structured link options (DOI, URL).
863    #[serde(skip_serializing_if = "Option::is_none")]
864    pub links: Option<crate::options::LinksConfig>,
865
866    /// Custom user-defined fields for extensions.
867    #[serde(skip_serializing_if = "Option::is_none")]
868    pub custom: Option<HashMap<String, serde_json::Value>>,
869}
870
871/// Types of titles.
872#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
873#[cfg_attr(feature = "schema", derive(JsonSchema))]
874#[serde(rename_all = "kebab-case")]
875#[non_exhaustive]
876pub enum TitleType {
877    /// The primary title of the cited work.
878    #[default]
879    Primary,
880    /// Title of the parent work containing the cited work.
881    ContainerTitle,
882    /// Title of a book/monograph containing the cited work.
883    ParentMonograph,
884    /// Title of a periodical/serial containing the cited work.
885    ParentSerial,
886    /// Title of a series or collection containing the cited work.
887    CollectionTitle,
888}
889
890/// Title rendering forms.
891#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
892#[cfg_attr(feature = "schema", derive(JsonSchema))]
893#[serde(rename_all = "kebab-case")]
894pub enum TitleForm {
895    Short,
896    #[default]
897    Long,
898}
899
900/// A number component (volume, issue, pages, etc.).
901#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
902#[cfg_attr(feature = "schema", derive(JsonSchema))]
903#[serde(rename_all = "kebab-case", deny_unknown_fields)]
904pub struct TemplateNumber {
905    pub number: NumberVariable,
906    #[serde(skip_serializing_if = "Option::is_none")]
907    pub form: Option<NumberForm>,
908    #[serde(skip_serializing_if = "Option::is_none")]
909    pub label_form: Option<LabelForm>,
910    /// When `true`, show this pages component even when a locator is present in a note-style citation.
911    /// By default, pages are suppressed in note-style citations when a locator is present.
912    #[serde(skip_serializing_if = "Option::is_none")]
913    pub show_with_locator: Option<bool>,
914    #[serde(flatten)]
915    pub rendering: Rendering,
916    /// Structured link options (DOI, URL).
917    #[serde(skip_serializing_if = "Option::is_none")]
918    pub links: Option<crate::options::LinksConfig>,
919    /// Explicit grammatical gender override for number/ordinal agreement.
920    #[serde(skip_serializing_if = "Option::is_none")]
921    pub gender: Option<GrammaticalGender>,
922
923    /// Custom user-defined fields for extensions.
924    #[serde(skip_serializing_if = "Option::is_none")]
925    pub custom: Option<HashMap<String, serde_json::Value>>,
926}
927
928/// Number variables.
929///
930/// Use `number:` when the value is treated as a number by the style:
931/// numeric labels, numeric-specific formatting, ordinals, roman numerals, or
932/// locator-aware punctuation. Use `variable:` instead when the field should be
933/// passed through as plain text without number formatting semantics.
934#[derive(Debug, Default, Clone)]
935#[non_exhaustive]
936pub enum NumberVariable {
937    #[default]
938    Volume,
939    Issue,
940    Pages,
941    Edition,
942    ChapterNumber,
943    CollectionNumber,
944    NumberOfPages,
945    NumberOfVolumes,
946    CitationNumber,
947    /// First-occurrence note number for the cited reference (note styles only).
948    /// Populated from the document processor; omitted (not rendered) when the
949    /// citation is not in a subsequent position or no first-note number is available.
950    FirstReferenceNoteNumber,
951    CitationLabel,
952    Number,
953    DocketNumber,
954    PatentNumber,
955    StandardNumber,
956    ReportNumber,
957    PartNumber,
958    SupplementNumber,
959    PrintingNumber,
960    /// A custom numbering variable rendered from an arbitrary numbering kind.
961    Custom(String),
962}
963
964impl NumberVariable {
965    /// Return the canonical kebab-case key for this numeric variable.
966    #[must_use]
967    pub fn as_key(&self) -> Cow<'_, str> {
968        match self {
969            Self::Volume => Cow::Borrowed("volume"),
970            Self::Issue => Cow::Borrowed("issue"),
971            Self::Pages => Cow::Borrowed("pages"),
972            Self::Edition => Cow::Borrowed("edition"),
973            Self::ChapterNumber => Cow::Borrowed("chapter-number"),
974            Self::CollectionNumber => Cow::Borrowed("collection-number"),
975            Self::NumberOfPages => Cow::Borrowed("number-of-pages"),
976            Self::NumberOfVolumes => Cow::Borrowed("number-of-volumes"),
977            Self::CitationNumber => Cow::Borrowed("citation-number"),
978            Self::FirstReferenceNoteNumber => Cow::Borrowed("first-reference-note-number"),
979            Self::CitationLabel => Cow::Borrowed("citation-label"),
980            Self::Number => Cow::Borrowed("number"),
981            Self::DocketNumber => Cow::Borrowed("docket-number"),
982            Self::PatentNumber => Cow::Borrowed("patent-number"),
983            Self::StandardNumber => Cow::Borrowed("standard-number"),
984            Self::ReportNumber => Cow::Borrowed("report-number"),
985            Self::PartNumber => Cow::Borrowed("part-number"),
986            Self::SupplementNumber => Cow::Borrowed("supplement-number"),
987            Self::PrintingNumber => Cow::Borrowed("printing-number"),
988            Self::Custom(value) => normalize_kind_key(value)
989                .map(Cow::Owned)
990                .unwrap_or_else(|| Cow::Borrowed(value.as_str())),
991        }
992    }
993
994    fn from_key(value: &str) -> Result<Self, String> {
995        let canonical = normalize_kind_key(value)
996            .ok_or_else(|| "number variable must not be empty".to_string())?;
997        Ok(match canonical.as_str() {
998            "volume" => Self::Volume,
999            "issue" => Self::Issue,
1000            "pages" => Self::Pages,
1001            "edition" => Self::Edition,
1002            "chapter-number" => Self::ChapterNumber,
1003            "collection-number" => Self::CollectionNumber,
1004            "number-of-pages" => Self::NumberOfPages,
1005            "number-of-volumes" => Self::NumberOfVolumes,
1006            "citation-number" => Self::CitationNumber,
1007            "first-reference-note-number" => Self::FirstReferenceNoteNumber,
1008            "citation-label" => Self::CitationLabel,
1009            "number" => Self::Number,
1010            "docket-number" => Self::DocketNumber,
1011            "patent-number" => Self::PatentNumber,
1012            "standard-number" => Self::StandardNumber,
1013            "report-number" => Self::ReportNumber,
1014            "part-number" => Self::PartNumber,
1015            "supplement-number" => Self::SupplementNumber,
1016            "printing-number" => Self::PrintingNumber,
1017            _ => Self::Custom(canonical),
1018        })
1019    }
1020}
1021
1022impl PartialEq for NumberVariable {
1023    fn eq(&self, other: &Self) -> bool {
1024        self.as_key().as_ref() == other.as_key().as_ref()
1025    }
1026}
1027
1028impl Eq for NumberVariable {}
1029
1030impl Hash for NumberVariable {
1031    fn hash<H: Hasher>(&self, state: &mut H) {
1032        self.as_key().as_ref().hash(state);
1033    }
1034}
1035
1036impl Serialize for NumberVariable {
1037    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1038    where
1039        S: Serializer,
1040    {
1041        serializer.serialize_str(self.as_key().as_ref())
1042    }
1043}
1044
1045impl<'de> Deserialize<'de> for NumberVariable {
1046    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1047    where
1048        D: Deserializer<'de>,
1049    {
1050        let value = String::deserialize(deserializer)?;
1051        Self::from_key(&value).map_err(serde::de::Error::custom)
1052    }
1053}
1054
1055#[cfg(feature = "schema")]
1056impl JsonSchema for NumberVariable {
1057    fn schema_name() -> std::borrow::Cow<'static, str> {
1058        "NumberVariable".into()
1059    }
1060
1061    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1062        schemars::json_schema!({
1063            "type": "string",
1064            "description": "Known number variable keyword or custom kebab-case identifier."
1065        })
1066    }
1067}
1068
1069/// Number rendering forms.
1070#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1071#[cfg_attr(feature = "schema", derive(JsonSchema))]
1072#[serde(rename_all = "lowercase")]
1073pub enum NumberForm {
1074    #[default]
1075    Numeric,
1076    Ordinal,
1077    Roman,
1078}
1079
1080fn normalize_kind_key(value: &str) -> Option<String> {
1081    let mut normalized = String::new();
1082    let mut pending_dash = false;
1083
1084    for ch in value.trim().chars() {
1085        if ch.is_ascii_alphanumeric() {
1086            if pending_dash && !normalized.is_empty() {
1087                normalized.push('-');
1088            }
1089            normalized.push(ch.to_ascii_lowercase());
1090            pending_dash = false;
1091        } else if !normalized.is_empty() {
1092            pending_dash = true;
1093        }
1094    }
1095
1096    if normalized.is_empty() {
1097        None
1098    } else {
1099        Some(normalized)
1100    }
1101}
1102
1103/// Label rendering forms.
1104#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1105#[cfg_attr(feature = "schema", derive(JsonSchema))]
1106#[serde(rename_all = "kebab-case")]
1107pub enum LabelForm {
1108    Long,
1109    #[default]
1110    Short,
1111    Symbol,
1112}
1113
1114/// A simple variable component (DOI, ISBN, URL, etc.).
1115#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1116#[cfg_attr(feature = "schema", derive(JsonSchema))]
1117#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1118pub struct TemplateVariable {
1119    pub variable: SimpleVariable,
1120    #[serde(flatten)]
1121    pub rendering: Rendering,
1122    /// Structured link options (DOI, URL).
1123    #[serde(skip_serializing_if = "Option::is_none")]
1124    pub links: Option<crate::options::LinksConfig>,
1125
1126    /// Custom user-defined fields for extensions.
1127    #[serde(skip_serializing_if = "Option::is_none")]
1128    pub custom: Option<HashMap<String, serde_json::Value>>,
1129}
1130
1131/// Simple string variables.
1132///
1133/// Use `variable:` for string passthrough fields, even when the field name is
1134/// also present in [`NumberVariable`]. For example, `variable: volume` keeps the
1135/// source value as plain text, while `number: volume` opts into numeric
1136/// formatting behavior.
1137#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1138#[cfg_attr(feature = "schema", derive(JsonSchema))]
1139#[serde(rename_all = "kebab-case")]
1140#[non_exhaustive]
1141pub enum SimpleVariable {
1142    #[default]
1143    Doi,
1144    Isbn,
1145    Issn,
1146    Url,
1147    Pmid,
1148    Pmcid,
1149    Abstract,
1150    Note,
1151    Annote,
1152    Keyword,
1153    Genre,
1154    Medium,
1155    Source,
1156    Status,
1157    Archive,
1158    ArchiveLocation,
1159    ArchiveName,
1160    ArchivePlace,
1161    ArchiveCollection,
1162    ArchiveCollectionId,
1163    ArchiveSeries,
1164    ArchiveBox,
1165    ArchiveFolder,
1166    ArchiveItem,
1167    ArchiveUrl,
1168    EprintId,
1169    EprintServer,
1170    EprintClass,
1171    Publisher,
1172    PublisherPlace,
1173    OriginalPublisher,
1174    OriginalPublisherPlace,
1175    EventPlace,
1176    Dimensions,
1177    Scale,
1178    Version,
1179    Locator,
1180    ContainerTitleShort,
1181    Authority,
1182    Code,
1183    Reporter,
1184    Page,
1185    Section,
1186    Volume,
1187    Number,
1188    DocketNumber,
1189    PatentNumber,
1190    StandardNumber,
1191    ReportNumber,
1192    AdsBibcode,
1193}
1194
1195/// A term component for rendering locale-specific text.
1196#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1197#[cfg_attr(feature = "schema", derive(JsonSchema))]
1198#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1199pub struct TemplateTerm {
1200    /// Which term to render.
1201    pub term: GeneralTerm,
1202    /// Form: long (default), short, or symbol.
1203    #[serde(skip_serializing_if = "Option::is_none")]
1204    pub form: Option<TermForm>,
1205    /// Explicit grammatical gender override for term selection.
1206    #[serde(skip_serializing_if = "Option::is_none")]
1207    pub gender: Option<GrammaticalGender>,
1208    #[serde(flatten, default)]
1209    pub rendering: Rendering,
1210
1211    /// Custom user-defined fields for extensions.
1212    #[serde(skip_serializing_if = "Option::is_none")]
1213    pub custom: Option<HashMap<String, serde_json::Value>>,
1214}
1215
1216/// A group component for grouping multiple components with a delimiter,
1217/// matching CSL 1.0 `<group>` semantics.
1218#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1219#[cfg_attr(feature = "schema", derive(JsonSchema))]
1220#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1221pub struct TemplateGroup {
1222    pub group: Vec<TemplateComponent>,
1223    #[serde(skip_serializing_if = "Option::is_none")]
1224    pub delimiter: Option<DelimiterPunctuation>,
1225    #[serde(flatten, default)]
1226    pub rendering: Rendering,
1227
1228    /// Custom user-defined fields for extensions.
1229    #[serde(skip_serializing_if = "Option::is_none")]
1230    pub custom: Option<HashMap<String, serde_json::Value>>,
1231}
1232
1233/// Delimiter punctuation options.
1234#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)]
1235#[serde(rename_all = "kebab-case")]
1236pub enum DelimiterPunctuation {
1237    #[default]
1238    Comma,
1239    Semicolon,
1240    Period,
1241    Colon,
1242    Ampersand,
1243    VerticalLine,
1244    Slash,
1245    Hyphen,
1246    Space,
1247    None,
1248    /// Custom delimiter string (e.g., ": ").
1249    #[serde(untagged)]
1250    Custom(String),
1251}
1252
1253#[cfg(feature = "schema")]
1254impl JsonSchema for DelimiterPunctuation {
1255    fn schema_name() -> std::borrow::Cow<'static, str> {
1256        "DelimiterPunctuation".into()
1257    }
1258
1259    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1260        schemars::json_schema!({"type": "string", "description": "Delimiter punctuation options."})
1261    }
1262}
1263
1264impl DelimiterPunctuation {
1265    /// Convert this delimiter to a string with trailing space.
1266    ///
1267    /// Returns the punctuation followed by a space, except for Space (single space) and None (empty string).
1268    pub fn to_string_with_space(&self) -> String {
1269        match self {
1270            Self::Comma => ", ".to_string(),
1271            Self::Semicolon => "; ".to_string(),
1272            Self::Period => ". ".to_string(),
1273            Self::Colon => ": ".to_string(),
1274            Self::Ampersand => " & ".to_string(),
1275            Self::VerticalLine => " | ".to_string(),
1276            Self::Slash => "/".to_string(),
1277            Self::Hyphen => "-".to_string(),
1278            Self::Space => " ".to_string(),
1279            Self::None => "".to_string(),
1280            Self::Custom(s) => s.clone(),
1281        }
1282    }
1283
1284    /// Parse a delimiter from a CSL 1.0 delimiter string.
1285    ///
1286    /// Handles common patterns like ", ", ": ", etc.
1287    /// Returns the Custom variant for unrecognized delimiters.
1288    pub fn from_csl_string(s: &str) -> Self {
1289        if s == " " {
1290            return Self::Space;
1291        }
1292
1293        let trimmed = s.trim();
1294        if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
1295            return Self::None;
1296        }
1297
1298        match trimmed {
1299            "," => Self::Comma,
1300            ";" => Self::Semicolon,
1301            "." => Self::Period,
1302            ":" => Self::Colon,
1303            "&" => Self::Ampersand,
1304            "|" => Self::VerticalLine,
1305            "/" => Self::Slash,
1306            "-" => Self::Hyphen,
1307            _ => Self::Custom(s.to_string()),
1308        }
1309    }
1310}
1311
1312#[cfg(test)]
1313#[allow(
1314    clippy::unwrap_used,
1315    clippy::expect_used,
1316    clippy::panic,
1317    clippy::indexing_slicing,
1318    clippy::todo,
1319    clippy::unimplemented,
1320    clippy::unreachable,
1321    clippy::get_unwrap,
1322    reason = "Panicking is acceptable and often desired in tests."
1323)]
1324mod tests {
1325    use super::*;
1326
1327    #[test]
1328    fn test_contributor_deserialization() {
1329        let yaml = r#"
1330contributor: author
1331form: long
1332"#;
1333        let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1334        assert_eq!(comp.contributor, ContributorRole::Author);
1335        assert_eq!(comp.form, ContributorForm::Long);
1336    }
1337
1338    #[test]
1339    fn test_template_component_untagged() {
1340        let yaml = r#"
1341- contributor: author
1342  form: short
1343- date: issued
1344  form: year
1345- title: primary
1346"#;
1347        let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1348        assert_eq!(components.len(), 3);
1349
1350        match &components[0] {
1351            TemplateComponent::Contributor(c) => {
1352                assert_eq!(c.contributor, ContributorRole::Author);
1353            }
1354            _ => panic!("Expected Contributor"),
1355        }
1356
1357        match &components[1] {
1358            TemplateComponent::Date(d) => {
1359                assert_eq!(d.date, DateVariable::Issued);
1360            }
1361            _ => panic!("Expected Date"),
1362        }
1363    }
1364
1365    #[test]
1366    fn test_flattened_rendering() {
1367        // Test that rendering options can be specified directly on the component
1368        let yaml = r#"
1369- title: parent-monograph
1370  prefix: "In "
1371  emph: true
1372- date: issued
1373  form: year
1374  wrap: parentheses
1375"#;
1376        let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1377        assert_eq!(components.len(), 2);
1378
1379        match &components[0] {
1380            TemplateComponent::Title(t) => {
1381                assert_eq!(t.rendering.prefix, Some("In ".to_string()));
1382                assert_eq!(t.rendering.emph, Some(true));
1383            }
1384            _ => panic!("Expected Title"),
1385        }
1386
1387        match &components[1] {
1388            TemplateComponent::Date(d) => {
1389                assert_eq!(
1390                    d.rendering.wrap,
1391                    Some(WrapConfig {
1392                        punctuation: WrapPunctuation::Parentheses,
1393                        inner_prefix: None,
1394                        inner_suffix: None,
1395                    })
1396                );
1397            }
1398            _ => panic!("Expected Date"),
1399        }
1400    }
1401
1402    #[test]
1403    fn test_number_variable_custom_normalizes_manual_construction() {
1404        let number = NumberVariable::Custom("Reel Label".to_string());
1405
1406        assert_eq!(number.as_key(), "reel-label");
1407        assert_eq!(
1408            number,
1409            serde_yaml::from_str::<NumberVariable>("reel-label")
1410                .expect("custom number variable should parse")
1411        );
1412        assert_eq!(
1413            serde_json::to_string(&number).expect("custom number variable should serialize"),
1414            "\"reel-label\""
1415        );
1416    }
1417
1418    #[test]
1419    fn test_contributor_with_wrap() {
1420        let yaml = r#"
1421contributor: publisher
1422form: short
1423wrap: parentheses
1424"#;
1425        let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1426        assert_eq!(comp.contributor, ContributorRole::Publisher);
1427        assert_eq!(
1428            comp.rendering.wrap,
1429            Some(WrapConfig {
1430                punctuation: WrapPunctuation::Parentheses,
1431                inner_prefix: None,
1432                inner_suffix: None,
1433            })
1434        );
1435    }
1436
1437    #[test]
1438    fn test_variable_deserialization() {
1439        // Test that `variable: publisher` parses as Variable, not Number
1440        let yaml = "variable: publisher\n";
1441        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1442        match comp {
1443            TemplateComponent::Variable(v) => {
1444                assert_eq!(v.variable, SimpleVariable::Publisher);
1445            }
1446            _ => panic!("Expected Variable(Publisher), got {:?}", comp),
1447        }
1448    }
1449
1450    #[test]
1451    fn test_variable_array_parsing() {
1452        let yaml = r#"
1453- variable: doi
1454  prefix: "https://doi.org/"
1455- variable: publisher
1456"#;
1457        let comps: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1458        assert_eq!(comps.len(), 2);
1459        match &comps[0] {
1460            TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Doi),
1461            _ => panic!("Expected Variable for doi, got {:?}", comps[0]),
1462        }
1463        match &comps[1] {
1464            TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Publisher),
1465            _ => panic!("Expected Variable for publisher, got {:?}", comps[1]),
1466        }
1467    }
1468
1469    #[test]
1470    fn test_type_selector_default_only_matches_default_context() {
1471        let selector = TypeSelector::Single("default".to_string());
1472        assert!(selector.matches("default"));
1473        assert!(!selector.matches("article-journal"));
1474
1475        let mixed = TypeSelector::Multiple(vec!["default".to_string(), "chapter".to_string()]);
1476        assert!(mixed.matches("default"));
1477        assert!(mixed.matches("chapter"));
1478        assert!(!mixed.matches("book"));
1479    }
1480
1481    #[test]
1482    fn test_template_component_selector_matches_nested_partial_group() {
1483        let component: TemplateComponent = serde_yaml::from_str(
1484            r#"
1485delimiter: ""
1486group:
1487- number: citation-number
1488  wrap:
1489    punctuation: brackets
1490- contributor: author
1491  form: long
1492"#,
1493        )
1494        .unwrap();
1495        let selector = TemplateComponentSelector {
1496            fields: BTreeMap::from([(
1497                "group".to_string(),
1498                serde_json::json!([
1499                    { "number": "citation-number" },
1500                    { "contributor": "author" }
1501                ]),
1502            )]),
1503        };
1504
1505        assert!(selector.matches(&component));
1506    }
1507
1508    #[test]
1509    fn test_delimiter_from_csl_string_normalizes_none_and_trimmed_values() {
1510        assert_eq!(
1511            DelimiterPunctuation::from_csl_string("none"),
1512            DelimiterPunctuation::None
1513        );
1514        assert_eq!(
1515            DelimiterPunctuation::from_csl_string(" none "),
1516            DelimiterPunctuation::None
1517        );
1518        assert_eq!(
1519            DelimiterPunctuation::from_csl_string(" "),
1520            DelimiterPunctuation::Space
1521        );
1522        assert_eq!(
1523            DelimiterPunctuation::from_csl_string(" : "),
1524            DelimiterPunctuation::Colon
1525        );
1526    }
1527}