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    /// Components rendered when the author slot has no contributor, no
884    /// title/editor/translator substitute matched, and `substitute.template`
885    /// is exhausted — e.g. `message: term.anonymous` for GB/T 7714's `佚名`
886    /// placeholder. Only consulted for `contributor: author`; other roles
887    /// (editor, translator, ...) are unaffected. Mirrors
888    /// `TemplateDate.fallback` in shape and in the "tried in order, first
889    /// non-empty wins" semantics. See `csl26-6eak`.
890    #[serde(skip_serializing_if = "Option::is_none")]
891    pub fallback: Option<Vec<TemplateComponent>>,
892    /// Optional role label configuration (e.g., "eds." for editors).
893    #[serde(skip_serializing_if = "Option::is_none")]
894    pub label: Option<RoleLabel>,
895    /// Configuration used when `contributor` is an ordered role list.
896    #[serde(skip_serializing_if = "Option::is_none")]
897    pub merge: Option<ContributorMerge>,
898    /// Override the global name order for this specific component.
899    /// Use to show editors as "Given Family" even when global setting is "Family, Given".
900    #[serde(skip_serializing_if = "Option::is_none")]
901    pub name_order: Option<NameOrder>,
902    /// Override the name form (e.g., initials, full, family-only) for this specific component.
903    #[serde(skip_serializing_if = "Option::is_none", rename = "name-form")]
904    pub name_form: Option<crate::options::contributors::NameForm>,
905    /// Custom delimiter between names (overrides global setting).
906    #[serde(skip_serializing_if = "Option::is_none")]
907    pub delimiter: Option<DelimiterPunctuation>,
908    /// Delimiter between family and given name when inverted (overrides global setting).
909    #[serde(skip_serializing_if = "Option::is_none")]
910    pub sort_separator: Option<String>,
911    /// Shorten the list of names (et al. configuration).
912    #[serde(skip_serializing_if = "Option::is_none")]
913    pub shorten: Option<crate::options::ShortenListOptions>,
914    /// Override the conjunction between the last two names.
915    /// Use `none` for bibliography when citation uses `text` or `symbol`.
916    #[serde(skip_serializing_if = "Option::is_none")]
917    pub and: Option<crate::options::AndOptions>,
918    #[serde(flatten, default)]
919    pub rendering: Rendering,
920    /// Structured link options (DOI, URL).
921    #[serde(skip_serializing_if = "Option::is_none")]
922    pub links: Option<crate::options::LinksConfig>,
923    /// Explicit grammatical gender override for role-label agreement.
924    #[serde(skip_serializing_if = "Option::is_none")]
925    pub gender: Option<GrammaticalGender>,
926
927    /// Custom user-defined fields for extensions.
928    #[serde(skip_serializing_if = "Option::is_none")]
929    pub custom: Option<HashMap<String, serde_json::Value>>,
930}
931
932/// Name display order.
933#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
934#[cfg_attr(feature = "schema", derive(JsonSchema))]
935#[serde(rename_all = "kebab-case")]
936pub enum NameOrder {
937    /// Display as "Given Family" (e.g., "John Smith").
938    GivenFirst,
939    /// Display as "Family, Given" (e.g., "Smith, John").
940    #[default]
941    FamilyFirst,
942    /// First contributor inverted ("Family, Given"); subsequent contributors given-first.
943    FamilyFirstOnly,
944    /// Every contributor except the last inverted ("Family, Given"); the last
945    /// contributor rendered given-first. "Last" is the last name of the full
946    /// contributor list; under et-al truncation that name may be elided, in
947    /// which case all rendered names invert.
948    FamilyFirstExceptLast,
949}
950
951/// How to render contributor names.
952#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
953#[cfg_attr(feature = "schema", derive(JsonSchema))]
954#[serde(rename_all = "kebab-case")]
955pub enum ContributorForm {
956    #[default]
957    Long,
958    Short,
959    FamilyOnly,
960    Verb,
961    VerbShort,
962}
963
964crate::str_enum! {
965    /// Contributor roles.
966    #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
967    pub enum ContributorRole {
968        #[default] Author = "author",
969        Chair = "chair",
970        Editor = "editor",
971        Translator = "translator",
972        Director = "director",
973        Publisher = "publisher",
974        Recipient = "recipient",
975        Interviewer = "interviewer",
976        Interviewee = "interviewee",
977        Guest = "guest",
978        Performer = "performer",
979        Inventor = "inventor",
980        Counsel = "counsel",
981        Composer = "composer",
982        Writer = "writer",
983        Producer = "producer",
984        CollectionEditor = "collection-editor",
985        ContainerAuthor = "container-author",
986        EditorialDirector = "editorial-director",
987        TextualEditor = "textual-editor",
988        Illustrator = "illustrator",
989        OriginalAuthor = "original-author",
990        ReviewedAuthor = "reviewed-author"
991    }
992}
993
994/// A date component for rendering dates.
995#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
996#[cfg_attr(feature = "schema", derive(JsonSchema))]
997#[serde(rename_all = "kebab-case", deny_unknown_fields)]
998pub struct TemplateDate {
999    pub date: DateVariable,
1000    pub form: DateForm,
1001    /// Authoritative fallback components used when the primary date is missing.
1002    ///
1003    /// When every component is empty, including for an empty list, the date is omitted.
1004    #[serde(skip_serializing_if = "Option::is_none")]
1005    pub fallback: Option<Vec<TemplateComponent>>,
1006    /// When true, never wrap this component's opaque calendar-date `note`
1007    /// (e.g. a Minguo/era annotation), regardless of the section's
1008    /// `note-wrap` setting. Use on the redundant occurrence when a style
1009    /// legitimately renders the same date variable more than once per item
1010    /// (e.g. a short front-matter year plus a full-precision date later in
1011    /// the body) so the annotation appears exactly once rather than on every
1012    /// occurrence. See `docs/specs/CALENDAR_DATE_ANNOTATIONS.md` and
1013    /// `csl26-gl0n`.
1014    #[serde(skip_serializing_if = "Option::is_none")]
1015    pub suppress_note: Option<bool>,
1016    /// When true, never inline a year-suffix disambiguator (e.g. "1947a")
1017    /// into this component's rendering, regardless of `hints.disamb_condition`.
1018    /// Use on the redundant occurrence when a style legitimately renders
1019    /// `issued` more than once per item — the mirror of `suppress_note`: a
1020    /// dual-date shape typically wants the suffix on the short front year
1021    /// and the calendar-note annotation on the full body date, so each flag
1022    /// suppresses the opposite occurrence's copy. See `csl26-6eak`.
1023    #[serde(skip_serializing_if = "Option::is_none")]
1024    pub suppress_disamb_suffix: Option<bool>,
1025    #[serde(flatten, default)]
1026    pub rendering: Rendering,
1027    /// Structured link options (DOI, URL).
1028    #[serde(skip_serializing_if = "Option::is_none")]
1029    pub links: Option<crate::options::LinksConfig>,
1030
1031    /// Custom user-defined fields for extensions.
1032    #[serde(skip_serializing_if = "Option::is_none")]
1033    pub custom: Option<HashMap<String, serde_json::Value>>,
1034}
1035
1036/// Date variables.
1037#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1038#[cfg_attr(feature = "schema", derive(JsonSchema))]
1039#[serde(rename_all = "kebab-case")]
1040pub enum DateVariable {
1041    #[default]
1042    Issued,
1043    Accessed,
1044    OriginalPublished,
1045    Submitted,
1046    EventDate,
1047    /// Copyright year, used as a publication-year substitute when the true
1048    /// issue date is unknown (e.g. GB/T 7714 §7.5.4.3's `c1988`).
1049    Copyright,
1050    /// Printing/impression year, another publication-year substitute (e.g.
1051    /// GB/T 7714 §7.5.4.3's `1995印刷`).
1052    Printing,
1053}
1054
1055crate::str_enum! {
1056    /// Date rendering forms.
1057    #[derive(Debug, Default, Clone, PartialEq)]
1058    pub enum DateForm {
1059        #[default]
1060        Year = "year",
1061        YearMonth = "year-month",
1062        /// Month name only, no year or day: "June" (e.g. magazines whose year
1063        /// is already supplied by the author-date position).
1064        Month = "month",
1065        Full = "full",
1066        MonthDay = "month-day",
1067        YearMonthDay = "year-month-day",
1068        DayMonthAbbrYear = "day-month-abbr-year",
1069        /// Abbreviated month + day + year in US order: "Jan 15, 2024".
1070        MonthAbbrDayYear = "month-abbr-day-year"
1071    }
1072}
1073
1074/// A title component.
1075#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1076#[cfg_attr(feature = "schema", derive(JsonSchema))]
1077#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1078pub struct TemplateTitle {
1079    pub title: TitleType,
1080    #[serde(skip_serializing_if = "Option::is_none")]
1081    pub form: Option<TitleForm>,
1082    /// When true, suppress this title component unless the reference needs
1083    /// disambiguation (i.e. multiple works by the same author appear in the
1084    /// document). Used by author-class styles (e.g. MLA) where the title
1085    /// appears in citations only to resolve same-author ambiguity.
1086    #[serde(skip_serializing_if = "Option::is_none")]
1087    pub disambiguate_only: Option<bool>,
1088    /// When true, remove every period from the rendered title text (e.g. an
1089    /// abbreviated journal name "Br. Med. J." → "Br Med J").
1090    ///
1091    /// Deliberately separate from the shared `Rendering::strip_periods`
1092    /// (which only trims a single *trailing* period elsewhere in the
1093    /// engine, e.g. term/number rendering): a title can legitimately
1094    /// contain a period as ordinary text (a proper noun, a domain name like
1095    /// "Merriam-Webster.com"), so full-period removal is opt-in per
1096    /// component rather than folded into the general-purpose flag.
1097    #[serde(skip_serializing_if = "Option::is_none")]
1098    pub strip_periods_all: Option<bool>,
1099    #[serde(flatten, default)]
1100    pub rendering: Rendering,
1101    /// Structured link options (DOI, URL).
1102    #[serde(skip_serializing_if = "Option::is_none")]
1103    pub links: Option<crate::options::LinksConfig>,
1104
1105    /// Custom user-defined fields for extensions.
1106    #[serde(skip_serializing_if = "Option::is_none")]
1107    pub custom: Option<HashMap<String, serde_json::Value>>,
1108}
1109
1110/// Types of titles.
1111#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1112#[cfg_attr(feature = "schema", derive(JsonSchema))]
1113#[serde(rename_all = "kebab-case")]
1114#[non_exhaustive]
1115pub enum TitleType {
1116    /// The primary title of the cited work.
1117    #[default]
1118    Primary,
1119    /// Title of the parent work containing the cited work.
1120    ContainerTitle,
1121    /// Title of a book/monograph containing the cited work.
1122    ParentMonograph,
1123    /// Title of a periodical/serial containing the cited work.
1124    ParentSerial,
1125    /// Title of a series or collection containing the cited work.
1126    CollectionTitle,
1127    /// Title of the work's original publication (e.g. a translation's source-language title).
1128    Original,
1129}
1130
1131/// Title rendering forms.
1132#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1133#[cfg_attr(feature = "schema", derive(JsonSchema))]
1134#[serde(rename_all = "kebab-case")]
1135pub enum TitleForm {
1136    Short,
1137    #[default]
1138    Long,
1139}
1140
1141/// A number component (volume, issue, pages, etc.).
1142#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1143#[cfg_attr(feature = "schema", derive(JsonSchema))]
1144#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1145pub struct TemplateNumber {
1146    pub number: NumberVariable,
1147    #[serde(skip_serializing_if = "Option::is_none")]
1148    pub form: Option<NumberForm>,
1149    #[serde(skip_serializing_if = "Option::is_none")]
1150    pub label_form: Option<LabelForm>,
1151    /// When `true`, show this pages component even when a locator is present in a note-style citation.
1152    /// By default, pages are suppressed in note-style citations when a locator is present.
1153    #[serde(skip_serializing_if = "Option::is_none")]
1154    pub show_with_locator: Option<bool>,
1155    #[serde(flatten)]
1156    pub rendering: Rendering,
1157    /// Structured link options (DOI, URL).
1158    #[serde(skip_serializing_if = "Option::is_none")]
1159    pub links: Option<crate::options::LinksConfig>,
1160    /// Explicit grammatical gender override for number/ordinal agreement.
1161    #[serde(skip_serializing_if = "Option::is_none")]
1162    pub gender: Option<GrammaticalGender>,
1163    /// When set, resolve this number's locale term (e.g. GB/T 7714's `edition`
1164    /// or `volume` general terms) at the given form and wrap the value with
1165    /// it — but only when the resolved value is numeric (citeproc-style
1166    /// `is-numeric`). Non-numeric values — including free-text editions
1167    /// (`修订版`) and pre-labeled volumes (`美国卷`) — render bare, since the
1168    /// source standard treats those as already-complete strings.
1169    ///
1170    /// The term text is locale-owned, not style-owned: a term containing a
1171    /// literal `%s` (e.g. zh-CN's `第%s卷`, matching the CSL-M source term)
1172    /// wraps the value at that position; a term without `%s` (e.g. `版`)
1173    /// follows the value as a space-separated suffix. See
1174    /// `docs/specs/TEMPLATE_V3.md` §2.4.
1175    #[serde(skip_serializing_if = "Option::is_none")]
1176    pub when_numeric: Option<LabelForm>,
1177
1178    /// Custom user-defined fields for extensions.
1179    #[serde(skip_serializing_if = "Option::is_none")]
1180    pub custom: Option<HashMap<String, serde_json::Value>>,
1181}
1182
1183/// Number variables.
1184///
1185/// Use `number:` when the value is treated as a number by the style:
1186/// numeric labels, numeric-specific formatting, ordinals, roman numerals, or
1187/// locator-aware punctuation. Use `variable:` instead when the field should be
1188/// passed through as plain text without number formatting semantics.
1189#[derive(Debug, Default, Clone)]
1190#[non_exhaustive]
1191pub enum NumberVariable {
1192    #[default]
1193    Volume,
1194    Issue,
1195    Pages,
1196    Edition,
1197    ChapterNumber,
1198    CollectionNumber,
1199    NumberOfPages,
1200    NumberOfVolumes,
1201    CitationNumber,
1202    /// First-occurrence note number for the cited reference (note styles only).
1203    /// Populated from the document processor; omitted (not rendered) when the
1204    /// citation is not in a subsequent position or no first-note number is available.
1205    FirstReferenceNoteNumber,
1206    CitationLabel,
1207    Number,
1208    DocketNumber,
1209    PatentNumber,
1210    StandardNumber,
1211    ReportNumber,
1212    PartNumber,
1213    SupplementNumber,
1214    PrintingNumber,
1215    /// A custom numbering variable rendered from an arbitrary numbering kind.
1216    Custom(String),
1217}
1218
1219impl NumberVariable {
1220    /// Return the canonical kebab-case key for this numeric variable.
1221    #[must_use]
1222    pub fn as_key(&self) -> Cow<'_, str> {
1223        match self {
1224            Self::Volume => Cow::Borrowed("volume"),
1225            Self::Issue => Cow::Borrowed("issue"),
1226            Self::Pages => Cow::Borrowed("pages"),
1227            Self::Edition => Cow::Borrowed("edition"),
1228            Self::ChapterNumber => Cow::Borrowed("chapter-number"),
1229            Self::CollectionNumber => Cow::Borrowed("collection-number"),
1230            Self::NumberOfPages => Cow::Borrowed("number-of-pages"),
1231            Self::NumberOfVolumes => Cow::Borrowed("number-of-volumes"),
1232            Self::CitationNumber => Cow::Borrowed("citation-number"),
1233            Self::FirstReferenceNoteNumber => Cow::Borrowed("first-reference-note-number"),
1234            Self::CitationLabel => Cow::Borrowed("citation-label"),
1235            Self::Number => Cow::Borrowed("number"),
1236            Self::DocketNumber => Cow::Borrowed("docket-number"),
1237            Self::PatentNumber => Cow::Borrowed("patent-number"),
1238            Self::StandardNumber => Cow::Borrowed("standard-number"),
1239            Self::ReportNumber => Cow::Borrowed("report-number"),
1240            Self::PartNumber => Cow::Borrowed("part-number"),
1241            Self::SupplementNumber => Cow::Borrowed("supplement-number"),
1242            Self::PrintingNumber => Cow::Borrowed("printing-number"),
1243            Self::Custom(value) => normalize_kind_key(value)
1244                .map(Cow::Owned)
1245                .unwrap_or_else(|| Cow::Borrowed(value.as_str())),
1246        }
1247    }
1248
1249    fn from_key(value: &str) -> Result<Self, String> {
1250        let canonical = normalize_kind_key(value)
1251            .ok_or_else(|| "number variable must not be empty".to_string())?;
1252        Ok(match canonical.as_str() {
1253            "volume" => Self::Volume,
1254            "issue" => Self::Issue,
1255            "pages" => Self::Pages,
1256            "edition" => Self::Edition,
1257            "chapter-number" => Self::ChapterNumber,
1258            "collection-number" => Self::CollectionNumber,
1259            "number-of-pages" => Self::NumberOfPages,
1260            "number-of-volumes" => Self::NumberOfVolumes,
1261            "citation-number" => Self::CitationNumber,
1262            "first-reference-note-number" => Self::FirstReferenceNoteNumber,
1263            "citation-label" => Self::CitationLabel,
1264            "number" => Self::Number,
1265            "docket-number" => Self::DocketNumber,
1266            "patent-number" => Self::PatentNumber,
1267            "standard-number" => Self::StandardNumber,
1268            "report-number" => Self::ReportNumber,
1269            "part-number" => Self::PartNumber,
1270            "supplement-number" => Self::SupplementNumber,
1271            "printing-number" => Self::PrintingNumber,
1272            _ => Self::Custom(canonical),
1273        })
1274    }
1275}
1276
1277impl PartialEq for NumberVariable {
1278    fn eq(&self, other: &Self) -> bool {
1279        self.as_key().as_ref() == other.as_key().as_ref()
1280    }
1281}
1282
1283impl Eq for NumberVariable {}
1284
1285impl Hash for NumberVariable {
1286    fn hash<H: Hasher>(&self, state: &mut H) {
1287        self.as_key().as_ref().hash(state);
1288    }
1289}
1290
1291impl Serialize for NumberVariable {
1292    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1293    where
1294        S: Serializer,
1295    {
1296        serializer.serialize_str(self.as_key().as_ref())
1297    }
1298}
1299
1300impl<'de> Deserialize<'de> for NumberVariable {
1301    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1302    where
1303        D: Deserializer<'de>,
1304    {
1305        let value = String::deserialize(deserializer)?;
1306        Self::from_key(&value).map_err(serde::de::Error::custom)
1307    }
1308}
1309
1310#[cfg(feature = "schema")]
1311impl JsonSchema for NumberVariable {
1312    fn schema_name() -> std::borrow::Cow<'static, str> {
1313        "NumberVariable".into()
1314    }
1315
1316    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1317        schemars::json_schema!({
1318            "type": "string",
1319            "description": "Known number variable keyword or custom kebab-case identifier."
1320        })
1321    }
1322}
1323
1324/// Number rendering forms.
1325#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1326#[cfg_attr(feature = "schema", derive(JsonSchema))]
1327#[serde(rename_all = "lowercase")]
1328pub enum NumberForm {
1329    #[default]
1330    Numeric,
1331    Ordinal,
1332    Roman,
1333}
1334
1335fn normalize_kind_key(value: &str) -> Option<String> {
1336    let mut normalized = String::new();
1337    let mut pending_dash = false;
1338
1339    for ch in value.trim().chars() {
1340        if ch.is_ascii_alphanumeric() {
1341            if pending_dash && !normalized.is_empty() {
1342                normalized.push('-');
1343            }
1344            normalized.push(ch.to_ascii_lowercase());
1345            pending_dash = false;
1346        } else if !normalized.is_empty() {
1347            pending_dash = true;
1348        }
1349    }
1350
1351    if normalized.is_empty() {
1352        None
1353    } else {
1354        Some(normalized)
1355    }
1356}
1357
1358/// Label rendering forms.
1359#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1360#[cfg_attr(feature = "schema", derive(JsonSchema))]
1361#[serde(rename_all = "kebab-case")]
1362pub enum LabelForm {
1363    Long,
1364    #[default]
1365    Short,
1366    Symbol,
1367}
1368
1369/// A simple variable component (DOI, ISBN, URL, etc.).
1370#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1371#[cfg_attr(feature = "schema", derive(JsonSchema))]
1372#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1373pub struct TemplateVariable {
1374    pub variable: SimpleVariable,
1375    #[serde(flatten)]
1376    pub rendering: Rendering,
1377    /// Structured link options (DOI, URL).
1378    #[serde(skip_serializing_if = "Option::is_none")]
1379    pub links: Option<crate::options::LinksConfig>,
1380
1381    /// Custom user-defined fields for extensions.
1382    #[serde(skip_serializing_if = "Option::is_none")]
1383    pub custom: Option<HashMap<String, serde_json::Value>>,
1384}
1385
1386/// A supplementary standardized identifier component.
1387#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1388#[cfg_attr(feature = "schema", derive(JsonSchema))]
1389#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1390pub struct TemplateIdentifier {
1391    /// Validated identifier name to render from `reference.identifiers`.
1392    pub identifier: crate::reference::IdentifierName,
1393    #[serde(flatten, default)]
1394    pub rendering: Rendering,
1395}
1396
1397/// An MF2 message call inside a citation or bibliography template.
1398///
1399/// The style chooses the message ID and supplies structured argument sources;
1400/// the message body comes from the style or active locale.
1401#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1402#[cfg_attr(feature = "schema", derive(JsonSchema))]
1403#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1404pub struct TemplateMessage {
1405    /// Locale message ID to evaluate, such as `pattern.accessed-date`.
1406    pub message: String,
1407    /// Optional term form used when `message` addresses a `term.*` locale item.
1408    #[serde(skip_serializing_if = "Option::is_none")]
1409    pub form: Option<TermForm>,
1410    /// Explicit grammatical gender override for term-backed message selection.
1411    #[serde(skip_serializing_if = "Option::is_none")]
1412    pub gender: Option<GrammaticalGender>,
1413    /// Named argument sources pre-rendered before message evaluation.
1414    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1415    pub args: HashMap<String, MessageArgSource>,
1416    #[serde(flatten, default)]
1417    pub rendering: Rendering,
1418
1419    /// Custom user-defined fields for extensions.
1420    #[serde(skip_serializing_if = "Option::is_none")]
1421    pub custom: Option<HashMap<String, serde_json::Value>>,
1422}
1423
1424/// A structured source for one named locale-message argument.
1425#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1426#[cfg_attr(feature = "schema", derive(JsonSchema))]
1427#[serde(untagged)]
1428pub enum MessageArgSource {
1429    /// A literal string argument.
1430    Literal { literal: String },
1431    /// The canonical reference-type key used for MF2 selection.
1432    ReferenceType {
1433        #[serde(rename = "reference-type")]
1434        reference_type: MessageReferenceTypeSource,
1435    },
1436    /// A carrier label derived from raw medium or online-resource metadata.
1437    Carrier { carrier: MessageCarrierSource },
1438    /// A rendered contributor argument.
1439    Contributor(Box<TemplateContributor>),
1440    /// A rendered date argument.
1441    Date(TemplateDate),
1442    /// A rendered group argument.
1443    Group(TemplateGroup),
1444    /// A rendered title argument.
1445    Title(TemplateTitle),
1446    /// A rendered number argument.
1447    Number(TemplateNumber),
1448    /// A rendered variable argument.
1449    Variable(TemplateVariable),
1450    /// A rendered locale term argument.
1451    Term(TemplateTerm),
1452}
1453
1454impl MessageArgSource {
1455    /// Convert this argument source into a normal template component when it
1456    /// should be rendered through the standard component pipeline.
1457    #[must_use]
1458    pub fn as_template_component(&self) -> Option<TemplateComponent> {
1459        match self {
1460            Self::Literal { .. } | Self::ReferenceType { .. } | Self::Carrier { .. } => None,
1461            Self::Contributor(component) => {
1462                Some(TemplateComponent::Contributor(component.as_ref().clone()))
1463            }
1464            Self::Date(component) => Some(TemplateComponent::Date(component.clone())),
1465            Self::Group(component) => Some(TemplateComponent::Group(component.clone())),
1466            Self::Title(component) => Some(TemplateComponent::Title(component.clone())),
1467            Self::Number(component) => Some(TemplateComponent::Number(component.clone())),
1468            Self::Variable(component) => Some(TemplateComponent::Variable(component.clone())),
1469            Self::Term(component) => Some(TemplateComponent::Term(component.clone())),
1470        }
1471    }
1472}
1473
1474/// Reference-type value exposed to a style-owned MF2 message.
1475#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
1476#[cfg_attr(feature = "schema", derive(JsonSchema))]
1477#[serde(rename_all = "kebab-case")]
1478pub enum MessageReferenceTypeSource {
1479    /// Use the canonical Citum reference-type key.
1480    Key,
1481}
1482
1483/// Carrier classification exposed to a style-owned MF2 message.
1484#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
1485#[cfg_attr(feature = "schema", derive(JsonSchema))]
1486#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1487pub struct MessageCarrierSource {
1488    /// Value used when URL, DOI, or CSTR identifies an online resource.
1489    pub online: String,
1490    /// Value used when neither a raw medium nor online metadata is available.
1491    pub absent: String,
1492}
1493
1494/// Simple string variables.
1495///
1496/// Use `variable:` for string passthrough fields, even when the field name is
1497/// also present in [`NumberVariable`]. For example, `variable: volume` keeps the
1498/// source value as plain text, while `number: volume` opts into numeric
1499/// formatting behavior.
1500#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1501#[cfg_attr(feature = "schema", derive(JsonSchema))]
1502#[serde(rename_all = "kebab-case")]
1503#[non_exhaustive]
1504pub enum SimpleVariable {
1505    #[default]
1506    Doi,
1507    Isbn,
1508    Issn,
1509    Url,
1510    Pmid,
1511    Pmcid,
1512    Abstract,
1513    Note,
1514    Annote,
1515    Keyword,
1516    Genre,
1517    RawGenre,
1518    Medium,
1519    RawMedium,
1520    Source,
1521    Status,
1522    Archive,
1523    ArchiveLocation,
1524    ArchiveName,
1525    ArchivePlace,
1526    ArchiveCollection,
1527    ArchiveCollectionId,
1528    ArchiveSeries,
1529    ArchiveBox,
1530    ArchiveFolder,
1531    ArchiveItem,
1532    ArchiveUrl,
1533    EprintId,
1534    EprintServer,
1535    EprintClass,
1536    Publisher,
1537    PublisherPlace,
1538    OriginalPublisher,
1539    OriginalPublisherPlace,
1540    EventTitle,
1541    EventPlace,
1542    Dimensions,
1543    References,
1544    Scale,
1545    Version,
1546    VolumeTitle,
1547    Locator,
1548    ContainerTitleShort,
1549    Authority,
1550    Code,
1551    Reporter,
1552    Page,
1553    Section,
1554    Volume,
1555    Number,
1556    DocketNumber,
1557    PatentNumber,
1558    StandardNumber,
1559    ReportNumber,
1560    AdsBibcode,
1561}
1562
1563/// A term component for rendering locale-specific text.
1564#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1565#[cfg_attr(feature = "schema", derive(JsonSchema))]
1566#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1567pub struct TemplateTerm {
1568    /// Which term to render.
1569    pub term: GeneralTerm,
1570    /// Form: long (default), short, or symbol.
1571    #[serde(skip_serializing_if = "Option::is_none")]
1572    pub form: Option<TermForm>,
1573    /// Explicit grammatical gender override for term selection.
1574    #[serde(skip_serializing_if = "Option::is_none")]
1575    pub gender: Option<GrammaticalGender>,
1576    #[serde(flatten, default)]
1577    pub rendering: Rendering,
1578
1579    /// Custom user-defined fields for extensions.
1580    #[serde(skip_serializing_if = "Option::is_none")]
1581    pub custom: Option<HashMap<String, serde_json::Value>>,
1582}
1583
1584/// Where a [`TemplateTypeLabel`] resolves its text from.
1585///
1586/// `#[non_exhaustive]` with a single variant today: the label always
1587/// describes the reference's own type. Kept as an enum (rather than a bare
1588/// marker field) so a future label source can be added without a schema
1589/// break.
1590#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
1591#[cfg_attr(feature = "schema", derive(JsonSchema))]
1592#[serde(rename_all = "kebab-case")]
1593#[non_exhaustive]
1594pub enum TypeLabelSource {
1595    /// Resolve the label from the reference's own type: prefer its
1596    /// `genre`/`medium`, falling back to a locale term keyed by `ref_type`.
1597    #[default]
1598    ReferenceType,
1599}
1600
1601/// A localized label describing the reference's own type (e.g. "Dataset",
1602/// "Classical work"), resolved from `genre`/`medium` with a locale-term
1603/// fallback keyed by `ref_type`.
1604///
1605/// Emits only the resolved term text — wrap it in `wrap: brackets` (or any
1606/// other `Rendering` option) at the style level to match a particular
1607/// style's presentation, the same as any other component.
1608///
1609/// See `docs/specs/TYPE_CLASSIFICATION_CENTRALIZATION.md`.
1610#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1611#[cfg_attr(feature = "schema", derive(JsonSchema))]
1612#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1613pub struct TemplateTypeLabel {
1614    /// The label's text source. Currently always `reference-type`.
1615    #[serde(rename = "type-label")]
1616    pub type_label: TypeLabelSource,
1617    #[serde(flatten, default)]
1618    pub rendering: Rendering,
1619
1620    /// Custom user-defined fields for extensions.
1621    #[serde(skip_serializing_if = "Option::is_none")]
1622    pub custom: Option<HashMap<String, serde_json::Value>>,
1623}
1624
1625/// A group component for grouping multiple components with a delimiter,
1626/// matching CSL 1.0 `<group>` semantics.
1627#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1628#[cfg_attr(feature = "schema", derive(JsonSchema))]
1629#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1630pub struct TemplateGroup {
1631    pub group: Vec<TemplateComponent>,
1632    /// Optional field-presence condition that controls whether the group renders.
1633    #[serde(skip_serializing_if = "Option::is_none")]
1634    pub render_when: Option<TemplateGroupCondition>,
1635    #[serde(skip_serializing_if = "Option::is_none")]
1636    pub delimiter: Option<DelimiterPunctuation>,
1637    #[serde(flatten, default)]
1638    pub rendering: Rendering,
1639
1640    /// Custom user-defined fields for extensions.
1641    #[serde(skip_serializing_if = "Option::is_none")]
1642    pub custom: Option<HashMap<String, serde_json::Value>>,
1643}
1644
1645/// Field-presence condition for rendering a template group.
1646#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1647#[cfg_attr(feature = "schema", derive(JsonSchema))]
1648#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1649pub struct TemplateGroupCondition {
1650    /// Required field that must be present for the group to render.
1651    #[serde(skip_serializing_if = "Option::is_none")]
1652    pub field_present: Option<TemplateConditionField>,
1653    /// Required field that must be absent for the group to render.
1654    #[serde(skip_serializing_if = "Option::is_none")]
1655    pub field_absent: Option<TemplateConditionField>,
1656}
1657
1658/// Reference fields that can be tested by a template group condition.
1659#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
1660#[cfg_attr(feature = "schema", derive(JsonSchema))]
1661#[serde(rename_all = "kebab-case")]
1662pub enum TemplateConditionField {
1663    /// The primary author contributor.
1664    Author,
1665    /// The editor contributor.
1666    Editor,
1667    /// The recipient contributor.
1668    Recipient,
1669    /// The translator contributor.
1670    Translator,
1671    /// The primary title.
1672    Title,
1673    /// The series or collection title.
1674    CollectionTitle,
1675    /// The issued date.
1676    Issued,
1677    /// The original publication date.
1678    OriginalPublished,
1679    /// The publisher name.
1680    Publisher,
1681    /// The original publisher name (e.g. a reprint's first publisher).
1682    OriginalPublisher,
1683    /// The original publisher place (e.g. a reprint's first place of publication).
1684    OriginalPublisherPlace,
1685    /// The original title (e.g. a translation's title in its source language).
1686    OriginalTitle,
1687    /// The DOI identifier.
1688    Doi,
1689    /// The reference genre or item type label.
1690    Genre,
1691    /// The archive or repository name.
1692    Archive,
1693    /// The archive shelfmark or repository location.
1694    ArchiveLocation,
1695    /// The volume number, or the issue number when volume is absent (i.e.
1696    /// "does this serial component have any volume/issue identifier at
1697    /// all?"). Used to detect online-first articles that have not yet been
1698    /// assigned to an issue, which need a full publication date instead of
1699    /// a bare year.
1700    VolumeOrIssue,
1701}
1702
1703/// Literal text or an explicit semantic punctuation mark.
1704///
1705/// YAML strings are always literal. Semantic marks use the explicit mapping
1706/// form `{ mark: comma }`, so a string such as `comma` is never interpreted as
1707/// punctuation intent.
1708#[derive(Debug, Default, Clone, PartialEq)]
1709pub enum DelimiterPunctuation {
1710    /// A semantic comma mark.
1711    #[default]
1712    Comma,
1713    /// A semantic semicolon mark.
1714    Semicolon,
1715    /// A semantic period mark.
1716    Period,
1717    /// A semantic colon mark.
1718    Colon,
1719    /// A semantic parentheses pair.
1720    Parentheses,
1721    /// A semantic brackets pair.
1722    Brackets,
1723    /// A literal ampersand delimiter retained for programmatic compatibility.
1724    Ampersand,
1725    /// A literal vertical-line delimiter retained for programmatic compatibility.
1726    VerticalLine,
1727    /// A literal slash delimiter retained for programmatic compatibility.
1728    Slash,
1729    /// A literal hyphen delimiter retained for programmatic compatibility.
1730    Hyphen,
1731    /// A literal space delimiter retained for programmatic compatibility.
1732    Space,
1733    /// An empty literal delimiter retained for programmatic compatibility.
1734    None,
1735    /// Literal punctuation or text (e.g., `": "` or `"comma"`).
1736    Custom(String),
1737}
1738
1739#[cfg(feature = "schema")]
1740impl JsonSchema for DelimiterPunctuation {
1741    fn schema_name() -> std::borrow::Cow<'static, str> {
1742        "DelimiterPunctuation".into()
1743    }
1744
1745    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1746        schemars::json_schema!({
1747            "oneOf": [
1748                {
1749                    "type": "string",
1750                    "description": "Literal punctuation or text."
1751                },
1752                {
1753                    "type": "object",
1754                    "additionalProperties": false,
1755                    "required": ["mark"],
1756                    "properties": {
1757                        "mark": {
1758                            "type": "string",
1759                            "enum": [
1760                                "comma",
1761                                "colon",
1762                                "semicolon",
1763                                "period",
1764                                "parentheses",
1765                                "brackets"
1766                            ]
1767                        }
1768                    }
1769                }
1770            ],
1771            "description": "Literal text or an explicit semantic punctuation mark."
1772        })
1773    }
1774}
1775
1776impl Serialize for DelimiterPunctuation {
1777    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1778        use serde::ser::SerializeMap as _;
1779
1780        let mark = match self {
1781            Self::Comma => Some("comma"),
1782            Self::Semicolon => Some("semicolon"),
1783            Self::Period => Some("period"),
1784            Self::Colon => Some("colon"),
1785            Self::Parentheses => Some("parentheses"),
1786            Self::Brackets => Some("brackets"),
1787            Self::Ampersand
1788            | Self::VerticalLine
1789            | Self::Slash
1790            | Self::Hyphen
1791            | Self::Space
1792            | Self::None
1793            | Self::Custom(_) => None,
1794        };
1795
1796        if let Some(mark) = mark {
1797            let mut map = serializer.serialize_map(Some(1))?;
1798            map.serialize_entry("mark", mark)?;
1799            map.end()
1800        } else {
1801            serializer.serialize_str(self.as_default_str())
1802        }
1803    }
1804}
1805
1806impl<'de> Deserialize<'de> for DelimiterPunctuation {
1807    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1808        #[derive(Deserialize)]
1809        #[serde(deny_unknown_fields)]
1810        struct MarkReference {
1811            mark: String,
1812        }
1813
1814        #[derive(Deserialize)]
1815        #[serde(untagged)]
1816        enum LiteralOrMark {
1817            Literal(String),
1818            Mark(MarkReference),
1819        }
1820
1821        match LiteralOrMark::deserialize(deserializer)? {
1822            LiteralOrMark::Literal(value) => Ok(Self::Custom(value)),
1823            LiteralOrMark::Mark(reference) => match reference.mark.as_str() {
1824                "comma" => Ok(Self::Comma),
1825                "colon" => Ok(Self::Colon),
1826                "semicolon" => Ok(Self::Semicolon),
1827                "period" => Ok(Self::Period),
1828                "parentheses" => Ok(Self::Parentheses),
1829                "brackets" => Ok(Self::Brackets),
1830                other => Err(serde::de::Error::unknown_variant(
1831                    other,
1832                    &[
1833                        "comma",
1834                        "colon",
1835                        "semicolon",
1836                        "period",
1837                        "parentheses",
1838                        "brackets",
1839                    ],
1840                )),
1841            },
1842        }
1843    }
1844}
1845
1846impl DelimiterPunctuation {
1847    /// Return whether this value carries semantic punctuation intent rather
1848    /// than literal text.
1849    #[must_use]
1850    pub fn is_semantic(&self) -> bool {
1851        matches!(
1852            self,
1853            Self::Comma
1854                | Self::Semicolon
1855                | Self::Period
1856                | Self::Colon
1857                | Self::Parentheses
1858                | Self::Brackets
1859        )
1860    }
1861
1862    /// Return the historical Latin/default literal form.
1863    #[must_use]
1864    pub fn as_default_str(&self) -> &str {
1865        match self {
1866            Self::Comma => ", ",
1867            Self::Semicolon => "; ",
1868            Self::Period => ". ",
1869            Self::Colon => ": ",
1870            Self::Parentheses => "()",
1871            Self::Brackets => "[]",
1872            Self::Ampersand => " & ",
1873            Self::VerticalLine => " | ",
1874            Self::Slash => "/",
1875            Self::Hyphen => "-",
1876            Self::Space => " ",
1877            Self::None => "",
1878            Self::Custom(value) => value,
1879        }
1880    }
1881
1882    /// Convert this delimiter to a string with trailing space.
1883    ///
1884    /// Returns the punctuation followed by a space, except for Space (single space) and None (empty string).
1885    pub fn to_string_with_space(&self) -> String {
1886        self.as_default_str().to_string()
1887    }
1888
1889    /// Parse a delimiter from a CSL 1.0 delimiter string.
1890    ///
1891    /// Handles common patterns like ", ", ": ", etc.
1892    /// Returns the Custom variant for unrecognized delimiters.
1893    pub fn from_csl_string(s: &str) -> Self {
1894        if s == " " {
1895            return Self::Space;
1896        }
1897
1898        let trimmed = s.trim();
1899        if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
1900            return Self::None;
1901        }
1902
1903        match trimmed {
1904            "," => Self::Comma,
1905            ";" => Self::Semicolon,
1906            "." => Self::Period,
1907            ":" => Self::Colon,
1908            "&" => Self::Ampersand,
1909            "|" => Self::VerticalLine,
1910            "/" => Self::Slash,
1911            "-" => Self::Hyphen,
1912            _ => Self::Custom(s.to_string()),
1913        }
1914    }
1915}
1916
1917impl std::ops::Deref for DelimiterPunctuation {
1918    type Target = str;
1919
1920    fn deref(&self) -> &Self::Target {
1921        self.as_default_str()
1922    }
1923}
1924
1925impl std::fmt::Display for DelimiterPunctuation {
1926    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1927        formatter.write_str(self.as_default_str())
1928    }
1929}
1930
1931impl From<String> for DelimiterPunctuation {
1932    fn from(value: String) -> Self {
1933        Self::Custom(value)
1934    }
1935}
1936
1937impl From<&str> for DelimiterPunctuation {
1938    fn from(value: &str) -> Self {
1939        Self::Custom(value.to_string())
1940    }
1941}
1942
1943#[cfg(test)]
1944#[allow(
1945    clippy::unwrap_used,
1946    clippy::expect_used,
1947    clippy::panic,
1948    clippy::indexing_slicing,
1949    clippy::todo,
1950    clippy::unimplemented,
1951    clippy::unreachable,
1952    clippy::get_unwrap,
1953    reason = "Panicking is acceptable and often desired in tests."
1954)]
1955mod tests {
1956    use super::*;
1957
1958    #[test]
1959    fn test_contributor_deserialization() {
1960        let yaml = r#"
1961contributor: author
1962form: long
1963"#;
1964        let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1965        assert_eq!(comp.contributor, ContributorRole::Author);
1966        assert_eq!(comp.form, ContributorForm::Long);
1967    }
1968
1969    #[test]
1970    fn test_contributor_name_order_family_first_except_last_deserialization() {
1971        let yaml = r#"
1972contributor: author
1973form: long
1974name-order: family-first-except-last
1975"#;
1976        let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1977        assert_eq!(comp.name_order, Some(NameOrder::FamilyFirstExceptLast));
1978    }
1979
1980    #[test]
1981    fn test_template_component_untagged() {
1982        let yaml = r#"
1983- contributor: author
1984  form: short
1985- date: issued
1986  form: year
1987- title: primary
1988"#;
1989        let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1990        assert_eq!(components.len(), 3);
1991
1992        match &components[0] {
1993            TemplateComponent::Contributor(c) => {
1994                assert_eq!(c.contributor, ContributorRole::Author);
1995            }
1996            _ => panic!("Expected Contributor"),
1997        }
1998
1999        match &components[1] {
2000            TemplateComponent::Date(d) => {
2001                assert_eq!(d.date, DateVariable::Issued);
2002            }
2003            _ => panic!("Expected Date"),
2004        }
2005    }
2006
2007    #[test]
2008    fn test_flattened_rendering() {
2009        // Test that rendering options can be specified directly on the component
2010        let yaml = r#"
2011- title: parent-monograph
2012  prefix: "In "
2013  emph: true
2014- date: issued
2015  form: year
2016  wrap: parentheses
2017"#;
2018        let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
2019        assert_eq!(components.len(), 2);
2020
2021        match &components[0] {
2022            TemplateComponent::Title(t) => {
2023                assert_eq!(t.rendering.prefix.as_deref(), Some("In "));
2024                assert_eq!(t.rendering.emph, Some(true));
2025            }
2026            _ => panic!("Expected Title"),
2027        }
2028
2029        match &components[1] {
2030            TemplateComponent::Date(d) => {
2031                assert_eq!(
2032                    d.rendering.wrap,
2033                    Some(WrapConfig {
2034                        punctuation: WrapPunctuation::Parentheses,
2035                        inner_prefix: None,
2036                        inner_suffix: None,
2037                    })
2038                );
2039            }
2040            _ => panic!("Expected Date"),
2041        }
2042    }
2043
2044    #[test]
2045    fn test_number_variable_custom_normalizes_manual_construction() {
2046        let number = NumberVariable::Custom("Reel Label".to_string());
2047
2048        assert_eq!(number.as_key(), "reel-label");
2049        assert_eq!(
2050            number,
2051            serde_yaml::from_str::<NumberVariable>("reel-label")
2052                .expect("custom number variable should parse")
2053        );
2054        assert_eq!(
2055            serde_json::to_string(&number).expect("custom number variable should serialize"),
2056            "\"reel-label\""
2057        );
2058    }
2059
2060    #[test]
2061    fn test_contributor_with_wrap() {
2062        let yaml = r#"
2063contributor: publisher
2064form: short
2065wrap: parentheses
2066"#;
2067        let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
2068        assert_eq!(comp.contributor, ContributorRole::Publisher);
2069        assert_eq!(
2070            comp.rendering.wrap,
2071            Some(WrapConfig {
2072                punctuation: WrapPunctuation::Parentheses,
2073                inner_prefix: None,
2074                inner_suffix: None,
2075            })
2076        );
2077    }
2078
2079    #[test]
2080    fn test_variable_deserialization() {
2081        // Test that `variable: publisher` parses as Variable, not Number
2082        let yaml = "variable: publisher\n";
2083        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
2084        match comp {
2085            TemplateComponent::Variable(v) => {
2086                assert_eq!(v.variable, SimpleVariable::Publisher);
2087            }
2088            _ => panic!("Expected Variable(Publisher), got {:?}", comp),
2089        }
2090    }
2091
2092    #[test]
2093    fn test_message_component_deserialization() {
2094        let yaml = r#"
2095message: pattern.in-container
2096args:
2097  container:
2098    group:
2099    - title: parent-monograph
2100      emph: true
2101text-case: capitalize-first
2102"#;
2103        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
2104
2105        match comp {
2106            TemplateComponent::Message(message) => {
2107                assert_eq!(message.message, "pattern.in-container");
2108                assert!(matches!(
2109                    message.args.get("container"),
2110                    Some(MessageArgSource::Group(group)) if group.group.len() == 1
2111                        && matches!(
2112                            group.group.first(),
2113                            Some(TemplateComponent::Title(title))
2114                                if title.title == TitleType::ParentMonograph
2115                                    && title.rendering.emph == Some(true)
2116                        )
2117                ));
2118                assert_eq!(
2119                    message.rendering.text_case,
2120                    Some(crate::options::titles::TextCase::CapitalizeFirst)
2121                );
2122            }
2123            _ => panic!("Expected Message component, got {comp:?}"),
2124        }
2125    }
2126
2127    #[test]
2128    fn test_term_backed_message_component_deserializes_form() {
2129        let yaml = r#"
2130message: term.in
2131form: long
2132suffix: ":"
2133"#;
2134        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
2135
2136        match comp {
2137            TemplateComponent::Message(message) => {
2138                assert_eq!(message.message, "term.in");
2139                assert_eq!(message.form, Some(TermForm::Long));
2140                assert_eq!(message.rendering.suffix.as_deref(), Some(":"));
2141            }
2142            _ => panic!("Expected Message component, got {comp:?}"),
2143        }
2144    }
2145
2146    #[test]
2147    fn test_group_deserializes_term_backed_message_component_with_form() {
2148        let yaml = r#"
2149group:
2150- message: term.in
2151  form: long
2152  suffix: ":"
2153- title: parent-monograph
2154"#;
2155        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
2156
2157        match comp {
2158            TemplateComponent::Group(group) => {
2159                assert!(matches!(
2160                    group.group.first(),
2161                    Some(TemplateComponent::Message(message))
2162                        if message.message == "term.in"
2163                            && message.form == Some(TermForm::Long)
2164                            && message.rendering.suffix.as_deref() == Some(":")
2165                ));
2166            }
2167            _ => panic!("Expected Group component, got {comp:?}"),
2168        }
2169    }
2170
2171    #[test]
2172    fn test_variable_array_parsing() {
2173        let yaml = r#"
2174- variable: doi
2175  prefix: "https://doi.org/"
2176- variable: publisher
2177"#;
2178        let comps: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
2179        assert_eq!(comps.len(), 2);
2180        match &comps[0] {
2181            TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Doi),
2182            _ => panic!("Expected Variable for doi, got {:?}", comps[0]),
2183        }
2184        match &comps[1] {
2185            TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Publisher),
2186            _ => panic!("Expected Variable for publisher, got {:?}", comps[1]),
2187        }
2188    }
2189
2190    #[test]
2191    fn test_type_selector_default_only_matches_default_context() {
2192        let selector = TypeSelector::Single("default".to_string());
2193        assert!(selector.matches("default"));
2194        assert!(!selector.matches("article-journal"));
2195
2196        let mixed = TypeSelector::Multiple(vec!["default".to_string(), "chapter".to_string()]);
2197        assert!(mixed.matches("default"));
2198        assert!(mixed.matches("chapter"));
2199        assert!(!mixed.matches("book"));
2200    }
2201
2202    #[test]
2203    fn test_template_component_selector_matches_nested_partial_group() {
2204        let component: TemplateComponent = serde_yaml::from_str(
2205            r#"
2206delimiter: ""
2207group:
2208- number: citation-number
2209  wrap:
2210    punctuation: brackets
2211- contributor: author
2212  form: long
2213"#,
2214        )
2215        .unwrap();
2216        let selector = TemplateComponentSelector {
2217            fields: BTreeMap::from([(
2218                "group".to_string(),
2219                serde_json::json!([
2220                    { "number": "citation-number" },
2221                    { "contributor": "author" }
2222                ]),
2223            )]),
2224        };
2225
2226        assert!(selector.matches(&component));
2227    }
2228
2229    #[test]
2230    fn test_delimiter_from_csl_string_normalizes_none_and_trimmed_values() {
2231        assert_eq!(
2232            DelimiterPunctuation::from_csl_string("none"),
2233            DelimiterPunctuation::None
2234        );
2235        assert_eq!(
2236            DelimiterPunctuation::from_csl_string(" none "),
2237            DelimiterPunctuation::None
2238        );
2239        assert_eq!(
2240            DelimiterPunctuation::from_csl_string(" "),
2241            DelimiterPunctuation::Space
2242        );
2243        assert_eq!(
2244            DelimiterPunctuation::from_csl_string(" : "),
2245            DelimiterPunctuation::Colon
2246        );
2247    }
2248}