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