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