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::matched_localized_template;
49pub use reference::{
50    LocalizedTemplateSpec, ResolvedLocalizedTemplate, TemplatePreset, TemplateReference,
51};
52pub(crate) use resolution::{inherited_variant_context, resolve_style_template_variants};
53
54/// Resolve a style's local template variants in place without inherited
55/// context, materializing every diff variant as a full template.
56///
57/// Diff variants are resolved against the style's own section templates and
58/// intra-section `extends` chains. Emitters that re-parent a style (for
59/// example the migration wrapper path) use this before attaching `extends`:
60/// a diff derived against the local template would otherwise resolve against
61/// the parent's same-selector variant at render time.
62///
63/// # Errors
64///
65/// Returns a [`crate::ResolutionError`] when a variant cycle, missing
66/// parent, or non-matching diff operation is found.
67pub fn resolve_local_template_variants(
68    style: &mut crate::Style,
69) -> Result<(), crate::ResolutionError> {
70    resolution::resolve_style_template_variants(style, None)
71}
72
73/// A named template (reusable sequence of components).
74pub type Template = Vec<TemplateComponent>;
75
76/// Type-specific template variants keyed by reference-type selector.
77pub type TemplateVariants = IndexMap<TypeSelector, TemplateVariant>;
78
79/// Locale-owned type-specific template replacements keyed by reference-type selector.
80///
81/// Localized variants are complete templates because they select after the section's
82/// main type-variant resolution has completed.
83pub type LocalizedTemplateVariants = IndexMap<TypeSelector, Template>;
84
85/// Vertical text alignment relative to the baseline.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[cfg_attr(feature = "schema", derive(JsonSchema))]
88#[serde(rename_all = "kebab-case")]
89pub enum VerticalAlign {
90    /// Render at the baseline (default).
91    Baseline,
92    /// Render as superscript.
93    Superscript,
94    /// Render as subscript.
95    Subscript,
96}
97
98/// Rendering instructions applied to template components.
99///
100/// These fields are flattened into parent structs, so in YAML you write:
101/// ```yaml
102/// - title: primary
103///   emph: true
104///   prefix: "In "
105/// ```
106/// Rather than nesting under a `rendering:` key.
107#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
108#[cfg_attr(feature = "schema", derive(JsonSchema))]
109#[serde(rename_all = "kebab-case", default)]
110pub struct Rendering {
111    /// Text-case transform to apply to the rendered value.
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub text_case: Option<crate::options::titles::TextCase>,
114    /// Render in italics/emphasis.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub emph: Option<bool>,
117    /// Render in quotes.
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub quote: Option<bool>,
120    /// Render in bold/strong.
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub strong: Option<bool>,
123    /// Render in small caps.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub small_caps: Option<bool>,
126    /// Vertical alignment to apply to rendered output.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub vertical_align: Option<VerticalAlign>,
129    /// Text or a semantic punctuation mark to prepend to the rendered value
130    /// (outside any wrap).
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub prefix: Option<DelimiterPunctuation>,
133    /// Text or a semantic punctuation mark to append to the rendered value
134    /// (outside any wrap).
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub suffix: Option<DelimiterPunctuation>,
137    /// Wrapping punctuation and optional inner affixes (text inside the wrap).
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub wrap: Option<WrapConfig>,
140    /// If true, suppress this component entirely (render as empty string).
141    /// Useful for type-specific overrides like suppressing publisher for journals.
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub suppress: Option<bool>,
144    /// Override name initialization (e.g., ". " or "").
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub initialize_with: Option<String>,
147    /// Override name form (e.g., initials, full, family-only).
148    #[serde(skip_serializing_if = "Option::is_none", rename = "name-form")]
149    pub name_form: Option<crate::options::contributors::NameForm>,
150    /// Strip trailing periods from rendered value.
151    #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
152    pub strip_periods: Option<bool>,
153}
154
155impl Rendering {
156    /// Merge another rendering configuration into this one.
157    ///
158    /// The other rendering takes precedence, overwriting any fields that are present.
159    pub fn merge(&mut self, other: &Rendering) {
160        crate::merge_options!(
161            self,
162            other,
163            text_case,
164            emph,
165            quote,
166            strong,
167            small_caps,
168            vertical_align,
169            prefix,
170            suffix,
171            wrap,
172            suppress,
173            initialize_with,
174            name_form,
175            strip_periods,
176        );
177    }
178}
179
180/// Punctuation to wrap a component in.
181#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
182#[cfg_attr(feature = "schema", derive(JsonSchema))]
183#[serde(rename_all = "kebab-case")]
184pub enum WrapPunctuation {
185    #[default]
186    Parentheses,
187    Brackets,
188    Quotes,
189}
190
191/// Wrapping punctuation and optional inner affixes applied around a rendered value.
192///
193/// Combines the wrap punctuation with optional text that appears inside the wrap
194/// (between the wrap character and the rendered content).
195#[derive(Debug, Clone, PartialEq, Serialize)]
196#[cfg_attr(feature = "schema", derive(JsonSchema))]
197#[serde(rename_all = "kebab-case")]
198pub struct WrapConfig {
199    /// The wrapping punctuation style.
200    pub punctuation: WrapPunctuation,
201    /// Text inserted after the opening wrap character but before the content.
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub inner_prefix: Option<String>,
204    /// Text inserted after the content but before the closing wrap character.
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub inner_suffix: Option<String>,
207}
208
209impl<'de> serde::Deserialize<'de> for WrapConfig {
210    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
211        struct WrapConfigVisitor;
212
213        impl<'de> serde::de::Visitor<'de> for WrapConfigVisitor {
214            type Value = WrapConfig;
215
216            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
217                write!(
218                    f,
219                    "a wrap punctuation string or a mapping with a 'punctuation' key"
220                )
221            }
222
223            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<WrapConfig, E> {
224                let punctuation = match v {
225                    "parentheses" => WrapPunctuation::Parentheses,
226                    "brackets" => WrapPunctuation::Brackets,
227                    "quotes" => WrapPunctuation::Quotes,
228                    other => {
229                        return Err(E::unknown_variant(
230                            other,
231                            &["parentheses", "brackets", "quotes"],
232                        ));
233                    }
234                };
235                Ok(WrapConfig {
236                    punctuation,
237                    inner_prefix: None,
238                    inner_suffix: None,
239                })
240            }
241
242            fn visit_map<A: serde::de::MapAccess<'de>>(
243                self,
244                mut map: A,
245            ) -> Result<WrapConfig, A::Error> {
246                let mut punctuation: Option<WrapPunctuation> = None;
247                let mut inner_prefix: Option<String> = None;
248                let mut inner_suffix: Option<String> = None;
249
250                while let Some(key) = map.next_key::<String>()? {
251                    match key.as_str() {
252                        "punctuation" => {
253                            punctuation = Some(map.next_value()?);
254                        }
255                        "inner-prefix" => {
256                            inner_prefix = Some(map.next_value()?);
257                        }
258                        "inner-suffix" => {
259                            inner_suffix = Some(map.next_value()?);
260                        }
261                        other => {
262                            return Err(serde::de::Error::unknown_field(
263                                other,
264                                &["punctuation", "inner-prefix", "inner-suffix"],
265                            ));
266                        }
267                    }
268                }
269
270                let punctuation =
271                    punctuation.ok_or_else(|| serde::de::Error::missing_field("punctuation"))?;
272                Ok(WrapConfig {
273                    punctuation,
274                    inner_prefix,
275                    inner_suffix,
276                })
277            }
278        }
279
280        deserializer.deserialize_any(WrapConfigVisitor)
281    }
282}
283
284impl From<WrapPunctuation> for WrapConfig {
285    fn from(punctuation: WrapPunctuation) -> Self {
286        WrapConfig {
287            punctuation,
288            inner_prefix: None,
289            inner_suffix: None,
290        }
291    }
292}
293
294/// Canonical reference type names recognized by the Citum engine.
295///
296/// Used by [`validate_type_name`] to detect likely typos.
297pub const VALID_TYPE_NAMES: &[&str] = &[
298    "book",
299    "manual",
300    "report",
301    "thesis",
302    "webpage",
303    "map",
304    "post",
305    "interview",
306    "manuscript",
307    "personal-communication",
308    "document",
309    "chapter",
310    "entry-dictionary",
311    "paper-conference",
312    "article-journal",
313    "article-magazine",
314    "article-newspaper",
315    "broadcast",
316    "motion-picture",
317    "collection",
318    "legal-case",
319    "statute",
320    "treaty",
321    "hearing",
322    "regulation",
323    "brief",
324    "classic",
325    "patent",
326    "dataset",
327    "standard",
328    "software",
329    // Special keywords
330    "all",
331    "default",
332];
333
334/// Returns `true` if `s` is a recognized reference type name.
335///
336/// Normalizes underscores to hyphens before comparing, so both
337/// `"article_journal"` and `"article-journal"` are accepted.
338/// Returns `false` for unrecognized names (likely typos).
339pub fn validate_type_name(s: &str) -> bool {
340    let normalized = s.replace('_', "-");
341    VALID_TYPE_NAMES.iter().any(|&known| known == normalized)
342}
343
344/// Selector for reference types in overrides.
345/// Can be a single type string or a list of types.
346#[derive(Debug, Clone, PartialEq, Eq, Hash)]
347#[cfg_attr(feature = "schema", derive(JsonSchema))]
348pub enum TypeSelector {
349    Single(String),
350    Multiple(Vec<String>),
351}
352
353impl Serialize for TypeSelector {
354    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
355    where
356        S: serde::Serializer,
357    {
358        serializer.serialize_str(&self.to_string())
359    }
360}
361
362impl<'de> Deserialize<'de> for TypeSelector {
363    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
364    where
365        D: serde::Deserializer<'de>,
366    {
367        struct Visitor;
368        impl<'de> serde::de::Visitor<'de> for Visitor {
369            type Value = TypeSelector;
370
371            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
372                formatter.write_str("a string or a sequence of strings")
373            }
374
375            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
376            where
377                E: serde::de::Error,
378            {
379                v.parse().map_err(E::custom)
380            }
381
382            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
383            where
384                A: serde::de::SeqAccess<'de>,
385            {
386                let mut types = Vec::new();
387                while let Some(t) = seq.next_element::<String>()? {
388                    types.push(t);
389                }
390                if types.len() == 1 {
391                    Ok(TypeSelector::Single(types.remove(0)))
392                } else {
393                    Ok(TypeSelector::Multiple(types))
394                }
395            }
396        }
397        deserializer.deserialize_any(Visitor)
398    }
399}
400
401impl std::fmt::Display for TypeSelector {
402    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
403        match self {
404            TypeSelector::Single(s) => write!(f, "{s}"),
405            TypeSelector::Multiple(types) => write!(f, "{}", types.join(",")),
406        }
407    }
408}
409
410impl std::str::FromStr for TypeSelector {
411    type Err = std::convert::Infallible;
412
413    fn from_str(s: &str) -> Result<Self, Self::Err> {
414        if s.contains(',') {
415            Ok(TypeSelector::Multiple(
416                s.split(',').map(|t| t.trim().to_string()).collect(),
417            ))
418        } else {
419            Ok(TypeSelector::Single(s.to_string()))
420        }
421    }
422}
423
424impl TypeSelector {
425    /// Check whether this selector matches a reference type.
426    ///
427    /// Type names are compared after normalizing underscores to hyphens, so
428    /// "legal_case" and "legal-case" are treated as equivalent (matching both
429    /// CSL 1.0 underscore convention and Citum hyphen convention).
430    ///
431    /// The special keyword "all" always matches any reference type.
432    pub fn matches(&self, ref_type: &str) -> bool {
433        let normalized_ref = ref_type.replace('_', "-");
434        let base_ref = normalized_ref
435            .split_once('+')
436            .map(|(base, _)| base)
437            .unwrap_or(&normalized_ref);
438        let eq = |s: &str| -> bool {
439            s == ref_type
440                || s.replace('_', "-") == normalized_ref
441                || s.replace('_', "-") == base_ref
442                || s == "all"
443                || (s == "default" && ref_type == "default")
444        };
445        match self {
446            TypeSelector::Single(s) => eq(s),
447            TypeSelector::Multiple(types) => types.iter().any(|t| eq(t)),
448        }
449    }
450
451    /// Returns any type names in this selector that are not in [`VALID_TYPE_NAMES`].
452    ///
453    /// An empty vec means all names are valid. Callers should emit a
454    /// [`crate::SchemaWarning`] for each returned name.
455    pub fn unknown_type_names(&self) -> Vec<&str> {
456        match self {
457            TypeSelector::Single(s) => {
458                if validate_type_name(s) {
459                    vec![]
460                } else {
461                    vec![s.as_str()]
462                }
463            }
464            TypeSelector::Multiple(types) => types
465                .iter()
466                .filter(|s| !validate_type_name(s))
467                .map(|s| s.as_str())
468                .collect(),
469        }
470    }
471}
472
473/// A template component - the building blocks of citation/bibliography templates.
474///
475/// Each variant handles a specific data type with appropriate formatting options.
476#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
477#[cfg_attr(feature = "schema", derive(JsonSchema))]
478#[serde(untagged)]
479#[non_exhaustive]
480pub enum TemplateComponent {
481    Contributor(TemplateContributor),
482    Date(TemplateDate),
483    Title(TemplateTitle),
484    Number(TemplateNumber),
485    Identifier(TemplateIdentifier),
486    Variable(TemplateVariable),
487    Message(TemplateMessage),
488    Group(TemplateGroup),
489    Term(TemplateTerm),
490    TypeLabel(TemplateTypeLabel),
491}
492
493impl Default for TemplateComponent {
494    fn default() -> Self {
495        TemplateComponent::Variable(TemplateVariable::default())
496    }
497}
498
499impl TemplateComponent {
500    /// Return the rendering options for this component.
501    ///
502    /// Every template component has rendering options like emphasis, wrapping, and prefixes.
503    pub fn rendering(&self) -> &Rendering {
504        crate::dispatch_component!(self, |inner| &inner.rendering)
505    }
506
507    /// Return the mutable rendering options for this component.
508    ///
509    /// Provides mutable access to rendering fields (prefix, suffix, etc.)
510    /// that are present on all template component variants.
511    pub fn rendering_mut(&mut self) -> &mut Rendering {
512        crate::dispatch_component!(self, |inner| &mut inner.rendering)
513    }
514}
515
516/// Type-specific template override, either as a complete legacy template or a V3 diff.
517#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
518#[cfg_attr(feature = "schema", derive(JsonSchema))]
519#[serde(untagged)]
520pub enum TemplateVariant {
521    /// Complete replacement template used by Template V1/V2 styles.
522    Full(Vec<TemplateComponent>),
523    /// Structural diff applied to a parent template during style resolution.
524    Diff(TemplateVariantDiff),
525}
526
527impl TemplateVariant {
528    /// Return this variant as a concrete template if it has already been resolved.
529    #[must_use]
530    pub fn as_template(&self) -> Option<&[TemplateComponent]> {
531        match self {
532            Self::Full(template) => Some(template.as_slice()),
533            Self::Diff(_) => None,
534        }
535    }
536
537    /// Return this variant as a mutable concrete template if it has already been resolved.
538    pub fn as_template_mut(&mut self) -> Option<&mut Vec<TemplateComponent>> {
539        match self {
540            Self::Full(template) => Some(template),
541            Self::Diff(_) => None,
542        }
543    }
544
545    /// Convert this variant into its concrete template if it has already been resolved.
546    #[must_use]
547    pub fn into_template(self) -> Option<Vec<TemplateComponent>> {
548        match self {
549            Self::Full(template) => Some(template),
550            Self::Diff(_) => None,
551        }
552    }
553}
554
555impl From<Vec<TemplateComponent>> for TemplateVariant {
556    fn from(template: Vec<TemplateComponent>) -> Self {
557        Self::Full(template)
558    }
559}
560
561/// Structural diff that derives a type-specific template from a parent template.
562#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
563#[cfg_attr(feature = "schema", derive(JsonSchema))]
564#[serde(rename_all = "kebab-case", deny_unknown_fields)]
565pub struct TemplateVariantDiff {
566    /// Optional parent type variant selector within the same section.
567    #[serde(skip_serializing_if = "Option::is_none")]
568    pub extends: Option<TypeSelector>,
569    /// Rendering-only modifications applied in authored order.
570    #[serde(skip_serializing_if = "Vec::is_empty", default)]
571    pub modify: Vec<TemplateModifyOperation>,
572    /// Component removals applied in authored order.
573    #[serde(skip_serializing_if = "Vec::is_empty", default)]
574    pub remove: Vec<TemplateRemoveOperation>,
575    /// Component additions applied in authored order.
576    #[serde(skip_serializing_if = "Vec::is_empty", default)]
577    pub add: Vec<TemplateAddOperation>,
578}
579
580/// Partial component selector used to locate anchors in a template.
581#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
582#[cfg_attr(feature = "schema", derive(JsonSchema))]
583#[serde(transparent)]
584pub struct TemplateComponentSelector {
585    /// Component fields that must be present with equal values on the target component.
586    pub fields: BTreeMap<String, serde_json::Value>,
587}
588
589impl TemplateComponentSelector {
590    /// Returns `true` when this selector has no fields.
591    #[must_use]
592    pub fn is_empty(&self) -> bool {
593        self.fields.is_empty()
594    }
595
596    /// Returns `true` when every selector field is present with the same value.
597    #[must_use]
598    pub fn matches(&self, component: &TemplateComponent) -> bool {
599        let Ok(serde_json::Value::Object(component_fields)) = serde_json::to_value(component)
600        else {
601            return false;
602        };
603
604        self.fields.iter().all(|(key, expected)| {
605            component_fields
606                .get(key)
607                .is_some_and(|actual| selector_value_matches(expected, actual))
608        })
609    }
610}
611
612fn selector_value_matches(expected: &serde_json::Value, actual: &serde_json::Value) -> bool {
613    match (expected, actual) {
614        (serde_json::Value::Object(expected_fields), serde_json::Value::Object(actual_fields)) => {
615            expected_fields.iter().all(|(key, expected_value)| {
616                actual_fields.get(key).is_some_and(|actual_value| {
617                    selector_value_matches(expected_value, actual_value)
618                })
619            })
620        }
621        (serde_json::Value::Array(expected_items), serde_json::Value::Array(actual_items)) => {
622            expected_items.len() == actual_items.len()
623                && expected_items.iter().zip(actual_items.iter()).all(
624                    |(expected_item, actual_item)| {
625                        selector_value_matches(expected_item, actual_item)
626                    },
627                )
628        }
629        _ => expected == actual,
630    }
631}
632
633/// Rendering-only modification for the component matched by `match`.
634#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
635#[cfg_attr(feature = "schema", derive(JsonSchema))]
636#[serde(rename_all = "kebab-case", deny_unknown_fields)]
637pub struct TemplateModifyOperation {
638    /// Selector identifying exactly one component to modify.
639    #[serde(rename = "match")]
640    pub match_selector: TemplateComponentSelector,
641    /// Override the localized number label form when modifying number components.
642    #[serde(skip_serializing_if = "Option::is_none")]
643    pub label_form: Option<LabelForm>,
644    /// Rendering fields to merge onto the matched component.
645    #[serde(flatten, default)]
646    pub rendering: Rendering,
647}
648
649/// Removal operation for the component matched by `match`.
650#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
651#[cfg_attr(feature = "schema", derive(JsonSchema))]
652#[serde(rename_all = "kebab-case", deny_unknown_fields)]
653pub struct TemplateRemoveOperation {
654    /// Selector identifying exactly one component to remove.
655    #[serde(rename = "match")]
656    pub match_selector: TemplateComponentSelector,
657}
658
659/// Addition operation that inserts a component before or after an anchor.
660#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
661#[cfg_attr(feature = "schema", derive(JsonSchema))]
662#[serde(rename_all = "kebab-case", deny_unknown_fields)]
663pub struct TemplateAddOperation {
664    /// Anchor selector before which the component should be inserted.
665    #[serde(skip_serializing_if = "Option::is_none")]
666    pub before: Option<TemplateComponentSelector>,
667    /// Anchor selector after which the component should be inserted.
668    #[serde(skip_serializing_if = "Option::is_none")]
669    pub after: Option<TemplateComponentSelector>,
670    /// Component to insert.
671    pub component: TemplateComponent,
672}
673
674/// Configuration for role labels (e.g., "eds.", "trans.").
675#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
676#[cfg_attr(feature = "schema", derive(JsonSchema))]
677#[serde(rename_all = "kebab-case")]
678pub struct RoleLabel {
679    /// Locale term key for the role (e.g., "editor", "translator").
680    pub term: String,
681    /// Term form: short ("eds.") or long ("editors").
682    #[serde(default)]
683    pub form: RoleLabelForm,
684    /// Where to place the label relative to names.
685    #[serde(default)]
686    pub placement: LabelPlacement,
687    /// Optional case transform applied to the resolved label term, e.g.
688    /// `capitalize-first` renders "Eds." from the locale's "eds." (as IEEE
689    /// requires). When unset the term is rendered as the locale stores it.
690    #[serde(default, skip_serializing_if = "Option::is_none")]
691    pub text_case: Option<crate::options::titles::TextCase>,
692    /// Optional punctuation wrapped around the resolved label term.
693    ///
694    /// The wrap is applied before the label's outer `prefix` and `suffix`.
695    #[serde(default, skip_serializing_if = "Option::is_none")]
696    pub wrap: Option<Box<WrapConfig>>,
697    /// Optional affix rendered before the label term, overriding the
698    /// placement-derived default (a space for a wrapped suffix label, `", "`
699    /// for an unwrapped suffix label, and empty for prefix placement). Mirrors
700    /// CSL 1.0 `cs:label` `prefix` (e.g. `" ("` for elsevier's `" (Eds.)"`).
701    #[serde(default, skip_serializing_if = "Option::is_none")]
702    pub prefix: Option<DelimiterPunctuation>,
703    /// Optional affix rendered after the label term, overriding the
704    /// placement-derived default (empty for suffix placement, `" "` for
705    /// prefix placement). Mirrors CSL 1.0 `cs:label` `suffix` (e.g. `")"`).
706    #[serde(default, skip_serializing_if = "Option::is_none")]
707    pub suffix: Option<DelimiterPunctuation>,
708}
709
710/// Term form for role labels.
711#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
712#[cfg_attr(feature = "schema", derive(JsonSchema))]
713#[serde(rename_all = "kebab-case")]
714pub enum RoleLabelForm {
715    #[default]
716    Short,
717    Long,
718}
719
720/// Label placement relative to contributor names.
721#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
722#[cfg_attr(feature = "schema", derive(JsonSchema))]
723#[serde(rename_all = "kebab-case")]
724pub enum LabelPlacement {
725    Prefix,
726    #[default]
727    Suffix,
728}
729
730/// One contributor role or an ordered list of roles rendered as one name list.
731#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
732#[cfg_attr(feature = "schema", derive(JsonSchema))]
733#[serde(untagged)]
734pub enum ContributorRoles {
735    /// A conventional single-role contributor component.
736    Single(ContributorRole),
737    /// Two or more contributor roles rendered as a merged list.
738    Multiple(#[cfg_attr(feature = "schema", schemars(length(min = 2)))] Vec<ContributorRole>),
739}
740
741impl Default for ContributorRoles {
742    fn default() -> Self {
743        Self::Single(ContributorRole::Author)
744    }
745}
746
747impl ContributorRoles {
748    /// Return all declared roles in authoring order.
749    #[must_use]
750    pub fn as_slice(&self) -> &[ContributorRole] {
751        match self {
752            Self::Single(role) => std::slice::from_ref(role),
753            Self::Multiple(roles) => roles,
754        }
755    }
756
757    /// Return the role when this is the scalar form.
758    #[must_use]
759    pub fn as_single(&self) -> Option<&ContributorRole> {
760        match self {
761            Self::Single(role) => Some(role),
762            Self::Multiple(_) => None,
763        }
764    }
765
766    /// Return whether this is the list form.
767    #[must_use]
768    pub fn is_multiple(&self) -> bool {
769        matches!(self, Self::Multiple(_))
770    }
771
772    /// Return whether the declaration contains `role`.
773    #[must_use]
774    pub fn contains(&self, role: &ContributorRole) -> bool {
775        self.as_slice().contains(role)
776    }
777}
778
779impl From<ContributorRole> for ContributorRoles {
780    fn from(role: ContributorRole) -> Self {
781        Self::Single(role)
782    }
783}
784
785impl From<Vec<ContributorRole>> for ContributorRoles {
786    fn from(roles: Vec<ContributorRole>) -> Self {
787        Self::Multiple(roles)
788    }
789}
790
791impl PartialEq<ContributorRole> for ContributorRoles {
792    fn eq(&self, other: &ContributorRole) -> bool {
793        self.as_single() == Some(other)
794    }
795}
796
797/// Ordering policy for a merged contributor list.
798#[derive(Debug, Default, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
799#[cfg_attr(feature = "schema", derive(JsonSchema))]
800#[serde(rename_all = "kebab-case")]
801pub enum ContributorMergeOrder {
802    /// Preserve the unified reference contributor order.
803    #[default]
804    Document,
805    /// Group entries by the component's declared role order.
806    Role,
807}
808
809/// Role-label placement mode for merged contributor entries.
810#[derive(Debug, Default, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
811#[cfg_attr(feature = "schema", derive(JsonSchema))]
812#[serde(rename_all = "kebab-case")]
813pub enum ContributorLabelMode {
814    /// Attach a singular role label to every rendered person.
815    #[default]
816    Individual,
817    /// Attach one singular or plural label to each contiguous role run.
818    Collective,
819    /// Render names without role labels.
820    None,
821}
822
823/// Per-role overrides within a merged contributor list.
824#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
825#[cfg_attr(feature = "schema", derive(JsonSchema))]
826#[serde(rename_all = "kebab-case", deny_unknown_fields)]
827pub struct ContributorMergeRole {
828    /// Override the merged list's default label mode for this role.
829    #[serde(skip_serializing_if = "Option::is_none")]
830    pub labels: Option<ContributorLabelMode>,
831    /// Override label term, form, placement, case, and affixes for this role.
832    #[serde(skip_serializing_if = "Option::is_none")]
833    pub label: Option<RoleLabel>,
834}
835
836/// Configuration for rendering multiple contributor roles as one name list.
837#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
838#[cfg_attr(feature = "schema", derive(JsonSchema))]
839#[serde(rename_all = "kebab-case", deny_unknown_fields)]
840pub struct ContributorMerge {
841    /// Effective ordering of entries in the merged list.
842    #[serde(default)]
843    pub order: ContributorMergeOrder,
844    /// Default role-label mode for entries in the merged list.
845    #[serde(default)]
846    pub labels: ContributorLabelMode,
847    /// Optional per-role label overrides.
848    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
849    pub roles: HashMap<ContributorRole, ContributorMergeRole>,
850    /// Whether identical people in different roles render as one entry.
851    #[serde(default = "default_combine_same_person")]
852    pub combine_same_person: bool,
853    /// Verbatim connector used when composing a missing combined-role term.
854    #[serde(skip_serializing_if = "Option::is_none")]
855    pub role_conjunction: Option<String>,
856}
857
858fn default_combine_same_person() -> bool {
859    true
860}
861
862impl Default for ContributorMerge {
863    fn default() -> Self {
864        Self {
865            order: ContributorMergeOrder::Document,
866            labels: ContributorLabelMode::Individual,
867            roles: HashMap::new(),
868            combine_same_person: true,
869            role_conjunction: None,
870        }
871    }
872}
873
874/// A contributor component for rendering names.
875#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
876#[cfg_attr(feature = "schema", derive(JsonSchema))]
877#[serde(rename_all = "kebab-case", deny_unknown_fields)]
878pub struct TemplateContributor {
879    /// Which contributor role or ordered role list to render.
880    pub contributor: ContributorRoles,
881    /// How to display the contributor (long names, short, with label, etc.).
882    pub form: ContributorForm,
883    /// Optional role label configuration (e.g., "eds." for editors).
884    #[serde(skip_serializing_if = "Option::is_none")]
885    pub label: Option<RoleLabel>,
886    /// Configuration used when `contributor` is an ordered role list.
887    #[serde(skip_serializing_if = "Option::is_none")]
888    pub merge: Option<ContributorMerge>,
889    /// Override the global name order for this specific component.
890    /// Use to show editors as "Given Family" even when global setting is "Family, Given".
891    #[serde(skip_serializing_if = "Option::is_none")]
892    pub name_order: Option<NameOrder>,
893    /// Override the name form (e.g., initials, full, family-only) for this specific component.
894    #[serde(skip_serializing_if = "Option::is_none", rename = "name-form")]
895    pub name_form: Option<crate::options::contributors::NameForm>,
896    /// Custom delimiter between names (overrides global setting).
897    #[serde(skip_serializing_if = "Option::is_none")]
898    pub delimiter: Option<DelimiterPunctuation>,
899    /// Delimiter between family and given name when inverted (overrides global setting).
900    #[serde(skip_serializing_if = "Option::is_none")]
901    pub sort_separator: Option<String>,
902    /// Shorten the list of names (et al. configuration).
903    #[serde(skip_serializing_if = "Option::is_none")]
904    pub shorten: Option<crate::options::ShortenListOptions>,
905    /// Override the conjunction between the last two names.
906    /// Use `none` for bibliography when citation uses `text` or `symbol`.
907    #[serde(skip_serializing_if = "Option::is_none")]
908    pub and: Option<crate::options::AndOptions>,
909    #[serde(flatten, default)]
910    pub rendering: Rendering,
911    /// Structured link options (DOI, URL).
912    #[serde(skip_serializing_if = "Option::is_none")]
913    pub links: Option<crate::options::LinksConfig>,
914    /// Explicit grammatical gender override for role-label agreement.
915    #[serde(skip_serializing_if = "Option::is_none")]
916    pub gender: Option<GrammaticalGender>,
917
918    /// Custom user-defined fields for extensions.
919    #[serde(skip_serializing_if = "Option::is_none")]
920    pub custom: Option<HashMap<String, serde_json::Value>>,
921}
922
923/// Name display order.
924#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
925#[cfg_attr(feature = "schema", derive(JsonSchema))]
926#[serde(rename_all = "kebab-case")]
927pub enum NameOrder {
928    /// Display as "Given Family" (e.g., "John Smith").
929    GivenFirst,
930    /// Display as "Family, Given" (e.g., "Smith, John").
931    #[default]
932    FamilyFirst,
933    /// First contributor inverted ("Family, Given"); subsequent contributors given-first.
934    FamilyFirstOnly,
935    /// Every contributor except the last inverted ("Family, Given"); the last
936    /// contributor rendered given-first. "Last" is the last name of the full
937    /// contributor list; under et-al truncation that name may be elided, in
938    /// which case all rendered names invert.
939    FamilyFirstExceptLast,
940}
941
942/// How to render contributor names.
943#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
944#[cfg_attr(feature = "schema", derive(JsonSchema))]
945#[serde(rename_all = "kebab-case")]
946pub enum ContributorForm {
947    #[default]
948    Long,
949    Short,
950    FamilyOnly,
951    Verb,
952    VerbShort,
953}
954
955crate::str_enum! {
956    /// Contributor roles.
957    #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
958    pub enum ContributorRole {
959        #[default] Author = "author",
960        Chair = "chair",
961        Editor = "editor",
962        Translator = "translator",
963        Director = "director",
964        Publisher = "publisher",
965        Recipient = "recipient",
966        Interviewer = "interviewer",
967        Interviewee = "interviewee",
968        Guest = "guest",
969        Performer = "performer",
970        Inventor = "inventor",
971        Counsel = "counsel",
972        Composer = "composer",
973        Writer = "writer",
974        Producer = "producer",
975        CollectionEditor = "collection-editor",
976        ContainerAuthor = "container-author",
977        EditorialDirector = "editorial-director",
978        TextualEditor = "textual-editor",
979        Illustrator = "illustrator",
980        OriginalAuthor = "original-author",
981        ReviewedAuthor = "reviewed-author"
982    }
983}
984
985/// A date component for rendering dates.
986#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
987#[cfg_attr(feature = "schema", derive(JsonSchema))]
988#[serde(rename_all = "kebab-case", deny_unknown_fields)]
989pub struct TemplateDate {
990    pub date: DateVariable,
991    pub form: DateForm,
992    /// Authoritative fallback components used when the primary date is missing.
993    ///
994    /// When every component is empty, including for an empty list, the date is omitted.
995    #[serde(skip_serializing_if = "Option::is_none")]
996    pub fallback: Option<Vec<TemplateComponent>>,
997    #[serde(flatten, default)]
998    pub rendering: Rendering,
999    /// Structured link options (DOI, URL).
1000    #[serde(skip_serializing_if = "Option::is_none")]
1001    pub links: Option<crate::options::LinksConfig>,
1002
1003    /// Custom user-defined fields for extensions.
1004    #[serde(skip_serializing_if = "Option::is_none")]
1005    pub custom: Option<HashMap<String, serde_json::Value>>,
1006}
1007
1008/// Date variables.
1009#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1010#[cfg_attr(feature = "schema", derive(JsonSchema))]
1011#[serde(rename_all = "kebab-case")]
1012pub enum DateVariable {
1013    #[default]
1014    Issued,
1015    Accessed,
1016    OriginalPublished,
1017    Submitted,
1018    EventDate,
1019    /// Copyright year, used as a publication-year substitute when the true
1020    /// issue date is unknown (e.g. GB/T 7714 §7.5.4.3's `c1988`).
1021    Copyright,
1022    /// Printing/impression year, another publication-year substitute (e.g.
1023    /// GB/T 7714 §7.5.4.3's `1995印刷`).
1024    Printing,
1025}
1026
1027crate::str_enum! {
1028    /// Date rendering forms.
1029    #[derive(Debug, Default, Clone, PartialEq)]
1030    pub enum DateForm {
1031        #[default]
1032        Year = "year",
1033        YearMonth = "year-month",
1034        /// Month name only, no year or day: "June" (e.g. magazines whose year
1035        /// is already supplied by the author-date position).
1036        Month = "month",
1037        Full = "full",
1038        MonthDay = "month-day",
1039        YearMonthDay = "year-month-day",
1040        DayMonthAbbrYear = "day-month-abbr-year",
1041        /// Abbreviated month + day + year in US order: "Jan 15, 2024".
1042        MonthAbbrDayYear = "month-abbr-day-year"
1043    }
1044}
1045
1046/// A title component.
1047#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1048#[cfg_attr(feature = "schema", derive(JsonSchema))]
1049#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1050pub struct TemplateTitle {
1051    pub title: TitleType,
1052    #[serde(skip_serializing_if = "Option::is_none")]
1053    pub form: Option<TitleForm>,
1054    /// When true, suppress this title component unless the reference needs
1055    /// disambiguation (i.e. multiple works by the same author appear in the
1056    /// document). Used by author-class styles (e.g. MLA) where the title
1057    /// appears in citations only to resolve same-author ambiguity.
1058    #[serde(skip_serializing_if = "Option::is_none")]
1059    pub disambiguate_only: Option<bool>,
1060    /// When true, remove every period from the rendered title text (e.g. an
1061    /// abbreviated journal name "Br. Med. J." → "Br Med J").
1062    ///
1063    /// Deliberately separate from the shared `Rendering::strip_periods`
1064    /// (which only trims a single *trailing* period elsewhere in the
1065    /// engine, e.g. term/number rendering): a title can legitimately
1066    /// contain a period as ordinary text (a proper noun, a domain name like
1067    /// "Merriam-Webster.com"), so full-period removal is opt-in per
1068    /// component rather than folded into the general-purpose flag.
1069    #[serde(skip_serializing_if = "Option::is_none")]
1070    pub strip_periods_all: Option<bool>,
1071    #[serde(flatten, default)]
1072    pub rendering: Rendering,
1073    /// Structured link options (DOI, URL).
1074    #[serde(skip_serializing_if = "Option::is_none")]
1075    pub links: Option<crate::options::LinksConfig>,
1076
1077    /// Custom user-defined fields for extensions.
1078    #[serde(skip_serializing_if = "Option::is_none")]
1079    pub custom: Option<HashMap<String, serde_json::Value>>,
1080}
1081
1082/// Types of titles.
1083#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1084#[cfg_attr(feature = "schema", derive(JsonSchema))]
1085#[serde(rename_all = "kebab-case")]
1086#[non_exhaustive]
1087pub enum TitleType {
1088    /// The primary title of the cited work.
1089    #[default]
1090    Primary,
1091    /// Title of the parent work containing the cited work.
1092    ContainerTitle,
1093    /// Title of a book/monograph containing the cited work.
1094    ParentMonograph,
1095    /// Title of a periodical/serial containing the cited work.
1096    ParentSerial,
1097    /// Title of a series or collection containing the cited work.
1098    CollectionTitle,
1099    /// Title of the work's original publication (e.g. a translation's source-language title).
1100    Original,
1101}
1102
1103/// Title 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 TitleForm {
1108    Short,
1109    #[default]
1110    Long,
1111}
1112
1113/// A number component (volume, issue, pages, etc.).
1114#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1115#[cfg_attr(feature = "schema", derive(JsonSchema))]
1116#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1117pub struct TemplateNumber {
1118    pub number: NumberVariable,
1119    #[serde(skip_serializing_if = "Option::is_none")]
1120    pub form: Option<NumberForm>,
1121    #[serde(skip_serializing_if = "Option::is_none")]
1122    pub label_form: Option<LabelForm>,
1123    /// When `true`, show this pages component even when a locator is present in a note-style citation.
1124    /// By default, pages are suppressed in note-style citations when a locator is present.
1125    #[serde(skip_serializing_if = "Option::is_none")]
1126    pub show_with_locator: Option<bool>,
1127    #[serde(flatten)]
1128    pub rendering: Rendering,
1129    /// Structured link options (DOI, URL).
1130    #[serde(skip_serializing_if = "Option::is_none")]
1131    pub links: Option<crate::options::LinksConfig>,
1132    /// Explicit grammatical gender override for number/ordinal agreement.
1133    #[serde(skip_serializing_if = "Option::is_none")]
1134    pub gender: Option<GrammaticalGender>,
1135    /// When set, resolve this number's locale term (e.g. GB/T 7714's `edition`
1136    /// or `volume` general terms) at the given form and wrap the value with
1137    /// it — but only when the resolved value is numeric (citeproc-style
1138    /// `is-numeric`). Non-numeric values — including free-text editions
1139    /// (`修订版`) and pre-labeled volumes (`美国卷`) — render bare, since the
1140    /// source standard treats those as already-complete strings.
1141    ///
1142    /// The term text is locale-owned, not style-owned: a term containing a
1143    /// literal `%s` (e.g. zh-CN's `第%s卷`, matching the CSL-M source term)
1144    /// wraps the value at that position; a term without `%s` (e.g. `版`)
1145    /// follows the value as a space-separated suffix. See
1146    /// `docs/specs/TEMPLATE_V3.md` §2.4.
1147    #[serde(skip_serializing_if = "Option::is_none")]
1148    pub when_numeric: Option<LabelForm>,
1149
1150    /// Custom user-defined fields for extensions.
1151    #[serde(skip_serializing_if = "Option::is_none")]
1152    pub custom: Option<HashMap<String, serde_json::Value>>,
1153}
1154
1155/// Number variables.
1156///
1157/// Use `number:` when the value is treated as a number by the style:
1158/// numeric labels, numeric-specific formatting, ordinals, roman numerals, or
1159/// locator-aware punctuation. Use `variable:` instead when the field should be
1160/// passed through as plain text without number formatting semantics.
1161#[derive(Debug, Default, Clone)]
1162#[non_exhaustive]
1163pub enum NumberVariable {
1164    #[default]
1165    Volume,
1166    Issue,
1167    Pages,
1168    Edition,
1169    ChapterNumber,
1170    CollectionNumber,
1171    NumberOfPages,
1172    NumberOfVolumes,
1173    CitationNumber,
1174    /// First-occurrence note number for the cited reference (note styles only).
1175    /// Populated from the document processor; omitted (not rendered) when the
1176    /// citation is not in a subsequent position or no first-note number is available.
1177    FirstReferenceNoteNumber,
1178    CitationLabel,
1179    Number,
1180    DocketNumber,
1181    PatentNumber,
1182    StandardNumber,
1183    ReportNumber,
1184    PartNumber,
1185    SupplementNumber,
1186    PrintingNumber,
1187    /// A custom numbering variable rendered from an arbitrary numbering kind.
1188    Custom(String),
1189}
1190
1191impl NumberVariable {
1192    /// Return the canonical kebab-case key for this numeric variable.
1193    #[must_use]
1194    pub fn as_key(&self) -> Cow<'_, str> {
1195        match self {
1196            Self::Volume => Cow::Borrowed("volume"),
1197            Self::Issue => Cow::Borrowed("issue"),
1198            Self::Pages => Cow::Borrowed("pages"),
1199            Self::Edition => Cow::Borrowed("edition"),
1200            Self::ChapterNumber => Cow::Borrowed("chapter-number"),
1201            Self::CollectionNumber => Cow::Borrowed("collection-number"),
1202            Self::NumberOfPages => Cow::Borrowed("number-of-pages"),
1203            Self::NumberOfVolumes => Cow::Borrowed("number-of-volumes"),
1204            Self::CitationNumber => Cow::Borrowed("citation-number"),
1205            Self::FirstReferenceNoteNumber => Cow::Borrowed("first-reference-note-number"),
1206            Self::CitationLabel => Cow::Borrowed("citation-label"),
1207            Self::Number => Cow::Borrowed("number"),
1208            Self::DocketNumber => Cow::Borrowed("docket-number"),
1209            Self::PatentNumber => Cow::Borrowed("patent-number"),
1210            Self::StandardNumber => Cow::Borrowed("standard-number"),
1211            Self::ReportNumber => Cow::Borrowed("report-number"),
1212            Self::PartNumber => Cow::Borrowed("part-number"),
1213            Self::SupplementNumber => Cow::Borrowed("supplement-number"),
1214            Self::PrintingNumber => Cow::Borrowed("printing-number"),
1215            Self::Custom(value) => normalize_kind_key(value)
1216                .map(Cow::Owned)
1217                .unwrap_or_else(|| Cow::Borrowed(value.as_str())),
1218        }
1219    }
1220
1221    fn from_key(value: &str) -> Result<Self, String> {
1222        let canonical = normalize_kind_key(value)
1223            .ok_or_else(|| "number variable must not be empty".to_string())?;
1224        Ok(match canonical.as_str() {
1225            "volume" => Self::Volume,
1226            "issue" => Self::Issue,
1227            "pages" => Self::Pages,
1228            "edition" => Self::Edition,
1229            "chapter-number" => Self::ChapterNumber,
1230            "collection-number" => Self::CollectionNumber,
1231            "number-of-pages" => Self::NumberOfPages,
1232            "number-of-volumes" => Self::NumberOfVolumes,
1233            "citation-number" => Self::CitationNumber,
1234            "first-reference-note-number" => Self::FirstReferenceNoteNumber,
1235            "citation-label" => Self::CitationLabel,
1236            "number" => Self::Number,
1237            "docket-number" => Self::DocketNumber,
1238            "patent-number" => Self::PatentNumber,
1239            "standard-number" => Self::StandardNumber,
1240            "report-number" => Self::ReportNumber,
1241            "part-number" => Self::PartNumber,
1242            "supplement-number" => Self::SupplementNumber,
1243            "printing-number" => Self::PrintingNumber,
1244            _ => Self::Custom(canonical),
1245        })
1246    }
1247}
1248
1249impl PartialEq for NumberVariable {
1250    fn eq(&self, other: &Self) -> bool {
1251        self.as_key().as_ref() == other.as_key().as_ref()
1252    }
1253}
1254
1255impl Eq for NumberVariable {}
1256
1257impl Hash for NumberVariable {
1258    fn hash<H: Hasher>(&self, state: &mut H) {
1259        self.as_key().as_ref().hash(state);
1260    }
1261}
1262
1263impl Serialize for NumberVariable {
1264    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1265    where
1266        S: Serializer,
1267    {
1268        serializer.serialize_str(self.as_key().as_ref())
1269    }
1270}
1271
1272impl<'de> Deserialize<'de> for NumberVariable {
1273    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1274    where
1275        D: Deserializer<'de>,
1276    {
1277        let value = String::deserialize(deserializer)?;
1278        Self::from_key(&value).map_err(serde::de::Error::custom)
1279    }
1280}
1281
1282#[cfg(feature = "schema")]
1283impl JsonSchema for NumberVariable {
1284    fn schema_name() -> std::borrow::Cow<'static, str> {
1285        "NumberVariable".into()
1286    }
1287
1288    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1289        schemars::json_schema!({
1290            "type": "string",
1291            "description": "Known number variable keyword or custom kebab-case identifier."
1292        })
1293    }
1294}
1295
1296/// Number rendering forms.
1297#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1298#[cfg_attr(feature = "schema", derive(JsonSchema))]
1299#[serde(rename_all = "lowercase")]
1300pub enum NumberForm {
1301    #[default]
1302    Numeric,
1303    Ordinal,
1304    Roman,
1305}
1306
1307fn normalize_kind_key(value: &str) -> Option<String> {
1308    let mut normalized = String::new();
1309    let mut pending_dash = false;
1310
1311    for ch in value.trim().chars() {
1312        if ch.is_ascii_alphanumeric() {
1313            if pending_dash && !normalized.is_empty() {
1314                normalized.push('-');
1315            }
1316            normalized.push(ch.to_ascii_lowercase());
1317            pending_dash = false;
1318        } else if !normalized.is_empty() {
1319            pending_dash = true;
1320        }
1321    }
1322
1323    if normalized.is_empty() {
1324        None
1325    } else {
1326        Some(normalized)
1327    }
1328}
1329
1330/// Label rendering forms.
1331#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1332#[cfg_attr(feature = "schema", derive(JsonSchema))]
1333#[serde(rename_all = "kebab-case")]
1334pub enum LabelForm {
1335    Long,
1336    #[default]
1337    Short,
1338    Symbol,
1339}
1340
1341/// A simple variable component (DOI, ISBN, URL, etc.).
1342#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1343#[cfg_attr(feature = "schema", derive(JsonSchema))]
1344#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1345pub struct TemplateVariable {
1346    pub variable: SimpleVariable,
1347    #[serde(flatten)]
1348    pub rendering: Rendering,
1349    /// Structured link options (DOI, URL).
1350    #[serde(skip_serializing_if = "Option::is_none")]
1351    pub links: Option<crate::options::LinksConfig>,
1352
1353    /// Custom user-defined fields for extensions.
1354    #[serde(skip_serializing_if = "Option::is_none")]
1355    pub custom: Option<HashMap<String, serde_json::Value>>,
1356}
1357
1358/// A supplementary standardized identifier component.
1359#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1360#[cfg_attr(feature = "schema", derive(JsonSchema))]
1361#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1362pub struct TemplateIdentifier {
1363    /// Validated identifier name to render from `reference.identifiers`.
1364    pub identifier: crate::reference::IdentifierName,
1365    #[serde(flatten, default)]
1366    pub rendering: Rendering,
1367}
1368
1369/// An MF2 message call inside a citation or bibliography template.
1370///
1371/// The style chooses the message ID and supplies structured argument sources;
1372/// the message body comes from the style or active locale.
1373#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1374#[cfg_attr(feature = "schema", derive(JsonSchema))]
1375#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1376pub struct TemplateMessage {
1377    /// Locale message ID to evaluate, such as `pattern.accessed-date`.
1378    pub message: String,
1379    /// Optional term form used when `message` addresses a `term.*` locale item.
1380    #[serde(skip_serializing_if = "Option::is_none")]
1381    pub form: Option<TermForm>,
1382    /// Explicit grammatical gender override for term-backed message selection.
1383    #[serde(skip_serializing_if = "Option::is_none")]
1384    pub gender: Option<GrammaticalGender>,
1385    /// Named argument sources pre-rendered before message evaluation.
1386    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1387    pub args: HashMap<String, MessageArgSource>,
1388    #[serde(flatten, default)]
1389    pub rendering: Rendering,
1390
1391    /// Custom user-defined fields for extensions.
1392    #[serde(skip_serializing_if = "Option::is_none")]
1393    pub custom: Option<HashMap<String, serde_json::Value>>,
1394}
1395
1396/// A structured source for one named locale-message argument.
1397#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1398#[cfg_attr(feature = "schema", derive(JsonSchema))]
1399#[serde(untagged)]
1400pub enum MessageArgSource {
1401    /// A literal string argument.
1402    Literal { literal: String },
1403    /// The canonical reference-type key used for MF2 selection.
1404    ReferenceType {
1405        #[serde(rename = "reference-type")]
1406        reference_type: MessageReferenceTypeSource,
1407    },
1408    /// A carrier label derived from raw medium or online-resource metadata.
1409    Carrier { carrier: MessageCarrierSource },
1410    /// A rendered contributor argument.
1411    Contributor(Box<TemplateContributor>),
1412    /// A rendered date argument.
1413    Date(TemplateDate),
1414    /// A rendered group argument.
1415    Group(TemplateGroup),
1416    /// A rendered title argument.
1417    Title(TemplateTitle),
1418    /// A rendered number argument.
1419    Number(TemplateNumber),
1420    /// A rendered variable argument.
1421    Variable(TemplateVariable),
1422    /// A rendered locale term argument.
1423    Term(TemplateTerm),
1424}
1425
1426impl MessageArgSource {
1427    /// Convert this argument source into a normal template component when it
1428    /// should be rendered through the standard component pipeline.
1429    #[must_use]
1430    pub fn as_template_component(&self) -> Option<TemplateComponent> {
1431        match self {
1432            Self::Literal { .. } | Self::ReferenceType { .. } | Self::Carrier { .. } => None,
1433            Self::Contributor(component) => {
1434                Some(TemplateComponent::Contributor(component.as_ref().clone()))
1435            }
1436            Self::Date(component) => Some(TemplateComponent::Date(component.clone())),
1437            Self::Group(component) => Some(TemplateComponent::Group(component.clone())),
1438            Self::Title(component) => Some(TemplateComponent::Title(component.clone())),
1439            Self::Number(component) => Some(TemplateComponent::Number(component.clone())),
1440            Self::Variable(component) => Some(TemplateComponent::Variable(component.clone())),
1441            Self::Term(component) => Some(TemplateComponent::Term(component.clone())),
1442        }
1443    }
1444}
1445
1446/// Reference-type value exposed to a style-owned MF2 message.
1447#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
1448#[cfg_attr(feature = "schema", derive(JsonSchema))]
1449#[serde(rename_all = "kebab-case")]
1450pub enum MessageReferenceTypeSource {
1451    /// Use the canonical Citum reference-type key.
1452    Key,
1453}
1454
1455/// Carrier classification exposed to a style-owned MF2 message.
1456#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
1457#[cfg_attr(feature = "schema", derive(JsonSchema))]
1458#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1459pub struct MessageCarrierSource {
1460    /// Value used when URL, DOI, or CSTR identifies an online resource.
1461    pub online: String,
1462    /// Value used when neither a raw medium nor online metadata is available.
1463    pub absent: String,
1464}
1465
1466/// Simple string variables.
1467///
1468/// Use `variable:` for string passthrough fields, even when the field name is
1469/// also present in [`NumberVariable`]. For example, `variable: volume` keeps the
1470/// source value as plain text, while `number: volume` opts into numeric
1471/// formatting behavior.
1472#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1473#[cfg_attr(feature = "schema", derive(JsonSchema))]
1474#[serde(rename_all = "kebab-case")]
1475#[non_exhaustive]
1476pub enum SimpleVariable {
1477    #[default]
1478    Doi,
1479    Isbn,
1480    Issn,
1481    Url,
1482    Pmid,
1483    Pmcid,
1484    Abstract,
1485    Note,
1486    Annote,
1487    Keyword,
1488    Genre,
1489    RawGenre,
1490    Medium,
1491    RawMedium,
1492    Source,
1493    Status,
1494    Archive,
1495    ArchiveLocation,
1496    ArchiveName,
1497    ArchivePlace,
1498    ArchiveCollection,
1499    ArchiveCollectionId,
1500    ArchiveSeries,
1501    ArchiveBox,
1502    ArchiveFolder,
1503    ArchiveItem,
1504    ArchiveUrl,
1505    EprintId,
1506    EprintServer,
1507    EprintClass,
1508    Publisher,
1509    PublisherPlace,
1510    OriginalPublisher,
1511    OriginalPublisherPlace,
1512    EventTitle,
1513    EventPlace,
1514    Dimensions,
1515    References,
1516    Scale,
1517    Version,
1518    VolumeTitle,
1519    Locator,
1520    ContainerTitleShort,
1521    Authority,
1522    Code,
1523    Reporter,
1524    Page,
1525    Section,
1526    Volume,
1527    Number,
1528    DocketNumber,
1529    PatentNumber,
1530    StandardNumber,
1531    ReportNumber,
1532    AdsBibcode,
1533}
1534
1535/// A term component for rendering locale-specific text.
1536#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1537#[cfg_attr(feature = "schema", derive(JsonSchema))]
1538#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1539pub struct TemplateTerm {
1540    /// Which term to render.
1541    pub term: GeneralTerm,
1542    /// Form: long (default), short, or symbol.
1543    #[serde(skip_serializing_if = "Option::is_none")]
1544    pub form: Option<TermForm>,
1545    /// Explicit grammatical gender override for term selection.
1546    #[serde(skip_serializing_if = "Option::is_none")]
1547    pub gender: Option<GrammaticalGender>,
1548    #[serde(flatten, default)]
1549    pub rendering: Rendering,
1550
1551    /// Custom user-defined fields for extensions.
1552    #[serde(skip_serializing_if = "Option::is_none")]
1553    pub custom: Option<HashMap<String, serde_json::Value>>,
1554}
1555
1556/// Where a [`TemplateTypeLabel`] resolves its text from.
1557///
1558/// `#[non_exhaustive]` with a single variant today: the label always
1559/// describes the reference's own type. Kept as an enum (rather than a bare
1560/// marker field) so a future label source can be added without a schema
1561/// break.
1562#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
1563#[cfg_attr(feature = "schema", derive(JsonSchema))]
1564#[serde(rename_all = "kebab-case")]
1565#[non_exhaustive]
1566pub enum TypeLabelSource {
1567    /// Resolve the label from the reference's own type: prefer its
1568    /// `genre`/`medium`, falling back to a locale term keyed by `ref_type`.
1569    #[default]
1570    ReferenceType,
1571}
1572
1573/// A localized label describing the reference's own type (e.g. "Dataset",
1574/// "Classical work"), resolved from `genre`/`medium` with a locale-term
1575/// fallback keyed by `ref_type`.
1576///
1577/// Emits only the resolved term text — wrap it in `wrap: brackets` (or any
1578/// other `Rendering` option) at the style level to match a particular
1579/// style's presentation, the same as any other component.
1580///
1581/// See `docs/specs/TYPE_CLASSIFICATION_CENTRALIZATION.md`.
1582#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1583#[cfg_attr(feature = "schema", derive(JsonSchema))]
1584#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1585pub struct TemplateTypeLabel {
1586    /// The label's text source. Currently always `reference-type`.
1587    #[serde(rename = "type-label")]
1588    pub type_label: TypeLabelSource,
1589    #[serde(flatten, default)]
1590    pub rendering: Rendering,
1591
1592    /// Custom user-defined fields for extensions.
1593    #[serde(skip_serializing_if = "Option::is_none")]
1594    pub custom: Option<HashMap<String, serde_json::Value>>,
1595}
1596
1597/// A group component for grouping multiple components with a delimiter,
1598/// matching CSL 1.0 `<group>` semantics.
1599#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1600#[cfg_attr(feature = "schema", derive(JsonSchema))]
1601#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1602pub struct TemplateGroup {
1603    pub group: Vec<TemplateComponent>,
1604    /// Optional field-presence condition that controls whether the group renders.
1605    #[serde(skip_serializing_if = "Option::is_none")]
1606    pub render_when: Option<TemplateGroupCondition>,
1607    #[serde(skip_serializing_if = "Option::is_none")]
1608    pub delimiter: Option<DelimiterPunctuation>,
1609    #[serde(flatten, default)]
1610    pub rendering: Rendering,
1611
1612    /// Custom user-defined fields for extensions.
1613    #[serde(skip_serializing_if = "Option::is_none")]
1614    pub custom: Option<HashMap<String, serde_json::Value>>,
1615}
1616
1617/// Field-presence condition for rendering a template group.
1618#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1619#[cfg_attr(feature = "schema", derive(JsonSchema))]
1620#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1621pub struct TemplateGroupCondition {
1622    /// Required field that must be present for the group to render.
1623    #[serde(skip_serializing_if = "Option::is_none")]
1624    pub field_present: Option<TemplateConditionField>,
1625    /// Required field that must be absent for the group to render.
1626    #[serde(skip_serializing_if = "Option::is_none")]
1627    pub field_absent: Option<TemplateConditionField>,
1628}
1629
1630/// Reference fields that can be tested by a template group condition.
1631#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
1632#[cfg_attr(feature = "schema", derive(JsonSchema))]
1633#[serde(rename_all = "kebab-case")]
1634pub enum TemplateConditionField {
1635    /// The primary author contributor.
1636    Author,
1637    /// The editor contributor.
1638    Editor,
1639    /// The recipient contributor.
1640    Recipient,
1641    /// The translator contributor.
1642    Translator,
1643    /// The primary title.
1644    Title,
1645    /// The series or collection title.
1646    CollectionTitle,
1647    /// The issued date.
1648    Issued,
1649    /// The original publication date.
1650    OriginalPublished,
1651    /// The publisher name.
1652    Publisher,
1653    /// The original publisher name (e.g. a reprint's first publisher).
1654    OriginalPublisher,
1655    /// The original publisher place (e.g. a reprint's first place of publication).
1656    OriginalPublisherPlace,
1657    /// The original title (e.g. a translation's title in its source language).
1658    OriginalTitle,
1659    /// The DOI identifier.
1660    Doi,
1661    /// The reference genre or item type label.
1662    Genre,
1663    /// The archive or repository name.
1664    Archive,
1665    /// The archive shelfmark or repository location.
1666    ArchiveLocation,
1667    /// The volume number, or the issue number when volume is absent (i.e.
1668    /// "does this serial component have any volume/issue identifier at
1669    /// all?"). Used to detect online-first articles that have not yet been
1670    /// assigned to an issue, which need a full publication date instead of
1671    /// a bare year.
1672    VolumeOrIssue,
1673}
1674
1675/// Literal text or an explicit semantic punctuation mark.
1676///
1677/// YAML strings are always literal. Semantic marks use the explicit mapping
1678/// form `{ mark: comma }`, so a string such as `comma` is never interpreted as
1679/// punctuation intent.
1680#[derive(Debug, Default, Clone, PartialEq)]
1681pub enum DelimiterPunctuation {
1682    /// A semantic comma mark.
1683    #[default]
1684    Comma,
1685    /// A semantic semicolon mark.
1686    Semicolon,
1687    /// A semantic period mark.
1688    Period,
1689    /// A semantic colon mark.
1690    Colon,
1691    /// A semantic parentheses pair.
1692    Parentheses,
1693    /// A semantic brackets pair.
1694    Brackets,
1695    /// A literal ampersand delimiter retained for programmatic compatibility.
1696    Ampersand,
1697    /// A literal vertical-line delimiter retained for programmatic compatibility.
1698    VerticalLine,
1699    /// A literal slash delimiter retained for programmatic compatibility.
1700    Slash,
1701    /// A literal hyphen delimiter retained for programmatic compatibility.
1702    Hyphen,
1703    /// A literal space delimiter retained for programmatic compatibility.
1704    Space,
1705    /// An empty literal delimiter retained for programmatic compatibility.
1706    None,
1707    /// Literal punctuation or text (e.g., `": "` or `"comma"`).
1708    Custom(String),
1709}
1710
1711#[cfg(feature = "schema")]
1712impl JsonSchema for DelimiterPunctuation {
1713    fn schema_name() -> std::borrow::Cow<'static, str> {
1714        "DelimiterPunctuation".into()
1715    }
1716
1717    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1718        schemars::json_schema!({
1719            "oneOf": [
1720                {
1721                    "type": "string",
1722                    "description": "Literal punctuation or text."
1723                },
1724                {
1725                    "type": "object",
1726                    "additionalProperties": false,
1727                    "required": ["mark"],
1728                    "properties": {
1729                        "mark": {
1730                            "type": "string",
1731                            "enum": [
1732                                "comma",
1733                                "colon",
1734                                "semicolon",
1735                                "period",
1736                                "parentheses",
1737                                "brackets"
1738                            ]
1739                        }
1740                    }
1741                }
1742            ],
1743            "description": "Literal text or an explicit semantic punctuation mark."
1744        })
1745    }
1746}
1747
1748impl Serialize for DelimiterPunctuation {
1749    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1750        use serde::ser::SerializeMap as _;
1751
1752        let mark = match self {
1753            Self::Comma => Some("comma"),
1754            Self::Semicolon => Some("semicolon"),
1755            Self::Period => Some("period"),
1756            Self::Colon => Some("colon"),
1757            Self::Parentheses => Some("parentheses"),
1758            Self::Brackets => Some("brackets"),
1759            Self::Ampersand
1760            | Self::VerticalLine
1761            | Self::Slash
1762            | Self::Hyphen
1763            | Self::Space
1764            | Self::None
1765            | Self::Custom(_) => None,
1766        };
1767
1768        if let Some(mark) = mark {
1769            let mut map = serializer.serialize_map(Some(1))?;
1770            map.serialize_entry("mark", mark)?;
1771            map.end()
1772        } else {
1773            serializer.serialize_str(self.as_default_str())
1774        }
1775    }
1776}
1777
1778impl<'de> Deserialize<'de> for DelimiterPunctuation {
1779    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1780        #[derive(Deserialize)]
1781        #[serde(deny_unknown_fields)]
1782        struct MarkReference {
1783            mark: String,
1784        }
1785
1786        #[derive(Deserialize)]
1787        #[serde(untagged)]
1788        enum LiteralOrMark {
1789            Literal(String),
1790            Mark(MarkReference),
1791        }
1792
1793        match LiteralOrMark::deserialize(deserializer)? {
1794            LiteralOrMark::Literal(value) => Ok(Self::Custom(value)),
1795            LiteralOrMark::Mark(reference) => match reference.mark.as_str() {
1796                "comma" => Ok(Self::Comma),
1797                "colon" => Ok(Self::Colon),
1798                "semicolon" => Ok(Self::Semicolon),
1799                "period" => Ok(Self::Period),
1800                "parentheses" => Ok(Self::Parentheses),
1801                "brackets" => Ok(Self::Brackets),
1802                other => Err(serde::de::Error::unknown_variant(
1803                    other,
1804                    &[
1805                        "comma",
1806                        "colon",
1807                        "semicolon",
1808                        "period",
1809                        "parentheses",
1810                        "brackets",
1811                    ],
1812                )),
1813            },
1814        }
1815    }
1816}
1817
1818impl DelimiterPunctuation {
1819    /// Return whether this value carries semantic punctuation intent rather
1820    /// than literal text.
1821    #[must_use]
1822    pub fn is_semantic(&self) -> bool {
1823        matches!(
1824            self,
1825            Self::Comma
1826                | Self::Semicolon
1827                | Self::Period
1828                | Self::Colon
1829                | Self::Parentheses
1830                | Self::Brackets
1831        )
1832    }
1833
1834    /// Return the historical Latin/default literal form.
1835    #[must_use]
1836    pub fn as_default_str(&self) -> &str {
1837        match self {
1838            Self::Comma => ", ",
1839            Self::Semicolon => "; ",
1840            Self::Period => ". ",
1841            Self::Colon => ": ",
1842            Self::Parentheses => "()",
1843            Self::Brackets => "[]",
1844            Self::Ampersand => " & ",
1845            Self::VerticalLine => " | ",
1846            Self::Slash => "/",
1847            Self::Hyphen => "-",
1848            Self::Space => " ",
1849            Self::None => "",
1850            Self::Custom(value) => value,
1851        }
1852    }
1853
1854    /// Convert this delimiter to a string with trailing space.
1855    ///
1856    /// Returns the punctuation followed by a space, except for Space (single space) and None (empty string).
1857    pub fn to_string_with_space(&self) -> String {
1858        self.as_default_str().to_string()
1859    }
1860
1861    /// Parse a delimiter from a CSL 1.0 delimiter string.
1862    ///
1863    /// Handles common patterns like ", ", ": ", etc.
1864    /// Returns the Custom variant for unrecognized delimiters.
1865    pub fn from_csl_string(s: &str) -> Self {
1866        if s == " " {
1867            return Self::Space;
1868        }
1869
1870        let trimmed = s.trim();
1871        if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
1872            return Self::None;
1873        }
1874
1875        match trimmed {
1876            "," => Self::Comma,
1877            ";" => Self::Semicolon,
1878            "." => Self::Period,
1879            ":" => Self::Colon,
1880            "&" => Self::Ampersand,
1881            "|" => Self::VerticalLine,
1882            "/" => Self::Slash,
1883            "-" => Self::Hyphen,
1884            _ => Self::Custom(s.to_string()),
1885        }
1886    }
1887}
1888
1889impl std::ops::Deref for DelimiterPunctuation {
1890    type Target = str;
1891
1892    fn deref(&self) -> &Self::Target {
1893        self.as_default_str()
1894    }
1895}
1896
1897impl std::fmt::Display for DelimiterPunctuation {
1898    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1899        formatter.write_str(self.as_default_str())
1900    }
1901}
1902
1903impl From<String> for DelimiterPunctuation {
1904    fn from(value: String) -> Self {
1905        Self::Custom(value)
1906    }
1907}
1908
1909impl From<&str> for DelimiterPunctuation {
1910    fn from(value: &str) -> Self {
1911        Self::Custom(value.to_string())
1912    }
1913}
1914
1915#[cfg(test)]
1916#[allow(
1917    clippy::unwrap_used,
1918    clippy::expect_used,
1919    clippy::panic,
1920    clippy::indexing_slicing,
1921    clippy::todo,
1922    clippy::unimplemented,
1923    clippy::unreachable,
1924    clippy::get_unwrap,
1925    reason = "Panicking is acceptable and often desired in tests."
1926)]
1927mod tests {
1928    use super::*;
1929
1930    #[test]
1931    fn test_contributor_deserialization() {
1932        let yaml = r#"
1933contributor: author
1934form: long
1935"#;
1936        let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1937        assert_eq!(comp.contributor, ContributorRole::Author);
1938        assert_eq!(comp.form, ContributorForm::Long);
1939    }
1940
1941    #[test]
1942    fn test_contributor_name_order_family_first_except_last_deserialization() {
1943        let yaml = r#"
1944contributor: author
1945form: long
1946name-order: family-first-except-last
1947"#;
1948        let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1949        assert_eq!(comp.name_order, Some(NameOrder::FamilyFirstExceptLast));
1950    }
1951
1952    #[test]
1953    fn test_template_component_untagged() {
1954        let yaml = r#"
1955- contributor: author
1956  form: short
1957- date: issued
1958  form: year
1959- title: primary
1960"#;
1961        let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1962        assert_eq!(components.len(), 3);
1963
1964        match &components[0] {
1965            TemplateComponent::Contributor(c) => {
1966                assert_eq!(c.contributor, ContributorRole::Author);
1967            }
1968            _ => panic!("Expected Contributor"),
1969        }
1970
1971        match &components[1] {
1972            TemplateComponent::Date(d) => {
1973                assert_eq!(d.date, DateVariable::Issued);
1974            }
1975            _ => panic!("Expected Date"),
1976        }
1977    }
1978
1979    #[test]
1980    fn test_flattened_rendering() {
1981        // Test that rendering options can be specified directly on the component
1982        let yaml = r#"
1983- title: parent-monograph
1984  prefix: "In "
1985  emph: true
1986- date: issued
1987  form: year
1988  wrap: parentheses
1989"#;
1990        let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1991        assert_eq!(components.len(), 2);
1992
1993        match &components[0] {
1994            TemplateComponent::Title(t) => {
1995                assert_eq!(t.rendering.prefix.as_deref(), Some("In "));
1996                assert_eq!(t.rendering.emph, Some(true));
1997            }
1998            _ => panic!("Expected Title"),
1999        }
2000
2001        match &components[1] {
2002            TemplateComponent::Date(d) => {
2003                assert_eq!(
2004                    d.rendering.wrap,
2005                    Some(WrapConfig {
2006                        punctuation: WrapPunctuation::Parentheses,
2007                        inner_prefix: None,
2008                        inner_suffix: None,
2009                    })
2010                );
2011            }
2012            _ => panic!("Expected Date"),
2013        }
2014    }
2015
2016    #[test]
2017    fn test_number_variable_custom_normalizes_manual_construction() {
2018        let number = NumberVariable::Custom("Reel Label".to_string());
2019
2020        assert_eq!(number.as_key(), "reel-label");
2021        assert_eq!(
2022            number,
2023            serde_yaml::from_str::<NumberVariable>("reel-label")
2024                .expect("custom number variable should parse")
2025        );
2026        assert_eq!(
2027            serde_json::to_string(&number).expect("custom number variable should serialize"),
2028            "\"reel-label\""
2029        );
2030    }
2031
2032    #[test]
2033    fn test_contributor_with_wrap() {
2034        let yaml = r#"
2035contributor: publisher
2036form: short
2037wrap: parentheses
2038"#;
2039        let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
2040        assert_eq!(comp.contributor, ContributorRole::Publisher);
2041        assert_eq!(
2042            comp.rendering.wrap,
2043            Some(WrapConfig {
2044                punctuation: WrapPunctuation::Parentheses,
2045                inner_prefix: None,
2046                inner_suffix: None,
2047            })
2048        );
2049    }
2050
2051    #[test]
2052    fn test_variable_deserialization() {
2053        // Test that `variable: publisher` parses as Variable, not Number
2054        let yaml = "variable: publisher\n";
2055        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
2056        match comp {
2057            TemplateComponent::Variable(v) => {
2058                assert_eq!(v.variable, SimpleVariable::Publisher);
2059            }
2060            _ => panic!("Expected Variable(Publisher), got {:?}", comp),
2061        }
2062    }
2063
2064    #[test]
2065    fn test_message_component_deserialization() {
2066        let yaml = r#"
2067message: pattern.in-container
2068args:
2069  container:
2070    group:
2071    - title: parent-monograph
2072      emph: true
2073text-case: capitalize-first
2074"#;
2075        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
2076
2077        match comp {
2078            TemplateComponent::Message(message) => {
2079                assert_eq!(message.message, "pattern.in-container");
2080                assert!(matches!(
2081                    message.args.get("container"),
2082                    Some(MessageArgSource::Group(group)) if group.group.len() == 1
2083                        && matches!(
2084                            group.group.first(),
2085                            Some(TemplateComponent::Title(title))
2086                                if title.title == TitleType::ParentMonograph
2087                                    && title.rendering.emph == Some(true)
2088                        )
2089                ));
2090                assert_eq!(
2091                    message.rendering.text_case,
2092                    Some(crate::options::titles::TextCase::CapitalizeFirst)
2093                );
2094            }
2095            _ => panic!("Expected Message component, got {comp:?}"),
2096        }
2097    }
2098
2099    #[test]
2100    fn test_term_backed_message_component_deserializes_form() {
2101        let yaml = r#"
2102message: term.in
2103form: long
2104suffix: ":"
2105"#;
2106        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
2107
2108        match comp {
2109            TemplateComponent::Message(message) => {
2110                assert_eq!(message.message, "term.in");
2111                assert_eq!(message.form, Some(TermForm::Long));
2112                assert_eq!(message.rendering.suffix.as_deref(), Some(":"));
2113            }
2114            _ => panic!("Expected Message component, got {comp:?}"),
2115        }
2116    }
2117
2118    #[test]
2119    fn test_group_deserializes_term_backed_message_component_with_form() {
2120        let yaml = r#"
2121group:
2122- message: term.in
2123  form: long
2124  suffix: ":"
2125- title: parent-monograph
2126"#;
2127        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
2128
2129        match comp {
2130            TemplateComponent::Group(group) => {
2131                assert!(matches!(
2132                    group.group.first(),
2133                    Some(TemplateComponent::Message(message))
2134                        if message.message == "term.in"
2135                            && message.form == Some(TermForm::Long)
2136                            && message.rendering.suffix.as_deref() == Some(":")
2137                ));
2138            }
2139            _ => panic!("Expected Group component, got {comp:?}"),
2140        }
2141    }
2142
2143    #[test]
2144    fn test_variable_array_parsing() {
2145        let yaml = r#"
2146- variable: doi
2147  prefix: "https://doi.org/"
2148- variable: publisher
2149"#;
2150        let comps: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
2151        assert_eq!(comps.len(), 2);
2152        match &comps[0] {
2153            TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Doi),
2154            _ => panic!("Expected Variable for doi, got {:?}", comps[0]),
2155        }
2156        match &comps[1] {
2157            TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Publisher),
2158            _ => panic!("Expected Variable for publisher, got {:?}", comps[1]),
2159        }
2160    }
2161
2162    #[test]
2163    fn test_type_selector_default_only_matches_default_context() {
2164        let selector = TypeSelector::Single("default".to_string());
2165        assert!(selector.matches("default"));
2166        assert!(!selector.matches("article-journal"));
2167
2168        let mixed = TypeSelector::Multiple(vec!["default".to_string(), "chapter".to_string()]);
2169        assert!(mixed.matches("default"));
2170        assert!(mixed.matches("chapter"));
2171        assert!(!mixed.matches("book"));
2172    }
2173
2174    #[test]
2175    fn test_template_component_selector_matches_nested_partial_group() {
2176        let component: TemplateComponent = serde_yaml::from_str(
2177            r#"
2178delimiter: ""
2179group:
2180- number: citation-number
2181  wrap:
2182    punctuation: brackets
2183- contributor: author
2184  form: long
2185"#,
2186        )
2187        .unwrap();
2188        let selector = TemplateComponentSelector {
2189            fields: BTreeMap::from([(
2190                "group".to_string(),
2191                serde_json::json!([
2192                    { "number": "citation-number" },
2193                    { "contributor": "author" }
2194                ]),
2195            )]),
2196        };
2197
2198        assert!(selector.matches(&component));
2199    }
2200
2201    #[test]
2202    fn test_delimiter_from_csl_string_normalizes_none_and_trimmed_values() {
2203        assert_eq!(
2204            DelimiterPunctuation::from_csl_string("none"),
2205            DelimiterPunctuation::None
2206        );
2207        assert_eq!(
2208            DelimiterPunctuation::from_csl_string(" none "),
2209            DelimiterPunctuation::None
2210        );
2211        assert_eq!(
2212            DelimiterPunctuation::from_csl_string(" "),
2213            DelimiterPunctuation::Space
2214        );
2215        assert_eq!(
2216            DelimiterPunctuation::from_csl_string(" : "),
2217            DelimiterPunctuation::Colon
2218        );
2219    }
2220}