Skip to main content

citum_schema_style/
template.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Template components for Citum styles.
7//!
8//! This module defines the declarative template language for Citum.
9//! Unlike CSL 1.0's procedural rendering elements, these components
10//! are simple, typed instructions that the processor interprets.
11//!
12//! ## Design Philosophy
13//!
14//! **Explicit over magic**: All rendering behavior should be expressible in the
15//! style YAML. The processor should not have hidden conditional logic based on
16//! reference types. Instead, use `overrides` to declare type-specific behavior.
17//!
18//! ## Type-Specific Overrides
19//!
20//! Components support `overrides` to customize rendering per reference type:
21//!
22//! ```yaml
23//! - variable: publisher
24//!   overrides:
25//!     article-journal:
26//!       suppress: true  # Don't show publisher for journals
27//! - number: pages
28//!   overrides:
29//!     chapter:
30//!       wrap: parentheses
31//!       label-form: short  # Show as "(pp. 1-10)" for English chapters
32//! ```
33//!
34//! This keeps all conditional logic in the style, making it testable and portable.
35
36use crate::locale::{GeneralTerm, GrammaticalGender, TermForm};
37use indexmap::IndexMap;
38#[cfg(feature = "schema")]
39use schemars::JsonSchema;
40use serde::{Deserialize, Deserializer, Serialize, Serializer};
41use std::borrow::Cow;
42use std::collections::{BTreeMap, HashMap};
43use std::hash::{Hash, Hasher};
44
45mod reference;
46pub(crate) mod resolution;
47
48pub(crate) use reference::locale_matches;
49pub use reference::{LocalizedTemplateSpec, TemplatePreset, TemplateReference};
50pub(crate) use resolution::{inherited_variant_context, resolve_style_template_variants};
51
52/// Resolve a style's local template variants in place without inherited
53/// context, materializing every diff variant as a full template.
54///
55/// Diff variants are resolved against the style's own section templates and
56/// intra-section `extends` chains. Emitters that re-parent a style (for
57/// example the migration wrapper path) use this before attaching `extends`:
58/// a diff derived against the local template would otherwise resolve against
59/// the parent's same-selector variant at render time.
60///
61/// # Errors
62///
63/// Returns a [`crate::ResolutionError`] when a variant cycle, missing
64/// parent, or non-matching diff operation is found.
65pub fn resolve_local_template_variants(
66    style: &mut crate::Style,
67) -> Result<(), crate::ResolutionError> {
68    resolution::resolve_style_template_variants(style, None)
69}
70
71/// A named template (reusable sequence of components).
72pub type Template = Vec<TemplateComponent>;
73
74/// Type-specific template variants keyed by reference-type selector.
75pub type TemplateVariants = IndexMap<TypeSelector, TemplateVariant>;
76
77/// Vertical text alignment relative to the baseline.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[cfg_attr(feature = "schema", derive(JsonSchema))]
80#[serde(rename_all = "kebab-case")]
81pub enum VerticalAlign {
82    /// Render at the baseline (default).
83    Baseline,
84    /// Render as superscript.
85    Superscript,
86    /// Render as subscript.
87    Subscript,
88}
89
90/// Rendering instructions applied to template components.
91///
92/// These fields are flattened into parent structs, so in YAML you write:
93/// ```yaml
94/// - title: primary
95///   emph: true
96///   prefix: "In "
97/// ```
98/// Rather than nesting under a `rendering:` key.
99#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
100#[cfg_attr(feature = "schema", derive(JsonSchema))]
101#[serde(rename_all = "kebab-case", default)]
102pub struct Rendering {
103    /// Text-case transform to apply to the rendered value.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub text_case: Option<crate::options::titles::TextCase>,
106    /// Render in italics/emphasis.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub emph: Option<bool>,
109    /// Render in quotes.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub quote: Option<bool>,
112    /// Render in bold/strong.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub strong: Option<bool>,
115    /// Render in small caps.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub small_caps: Option<bool>,
118    /// Vertical alignment to apply to rendered output.
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub vertical_align: Option<VerticalAlign>,
121    /// Text to prepend to the rendered value (outside any wrap).
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub prefix: Option<String>,
124    /// Text to append to the rendered value (outside any wrap).
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub suffix: Option<String>,
127    /// Wrapping punctuation and optional inner affixes (text inside the wrap).
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub wrap: Option<WrapConfig>,
130    /// If true, suppress this component entirely (render as empty string).
131    /// Useful for type-specific overrides like suppressing publisher for journals.
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub suppress: Option<bool>,
134    /// Override name initialization (e.g., ". " or "").
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub initialize_with: Option<String>,
137    /// Override name form (e.g., initials, full, family-only).
138    #[serde(skip_serializing_if = "Option::is_none", rename = "name-form")]
139    pub name_form: Option<crate::options::contributors::NameForm>,
140    /// Strip trailing periods from rendered value.
141    #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
142    pub strip_periods: Option<bool>,
143}
144
145impl Rendering {
146    /// Merge another rendering configuration into this one.
147    ///
148    /// The other rendering takes precedence, overwriting any fields that are present.
149    pub fn merge(&mut self, other: &Rendering) {
150        crate::merge_options!(
151            self,
152            other,
153            text_case,
154            emph,
155            quote,
156            strong,
157            small_caps,
158            vertical_align,
159            prefix,
160            suffix,
161            wrap,
162            suppress,
163            initialize_with,
164            name_form,
165            strip_periods,
166        );
167    }
168}
169
170/// Punctuation to wrap a component in.
171#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
172#[cfg_attr(feature = "schema", derive(JsonSchema))]
173#[serde(rename_all = "kebab-case")]
174pub enum WrapPunctuation {
175    #[default]
176    Parentheses,
177    Brackets,
178    Quotes,
179}
180
181/// Wrapping punctuation and optional inner affixes applied around a rendered value.
182///
183/// Combines the wrap punctuation with optional text that appears inside the wrap
184/// (between the wrap character and the rendered content).
185#[derive(Debug, Clone, PartialEq, Serialize)]
186#[cfg_attr(feature = "schema", derive(JsonSchema))]
187#[serde(rename_all = "kebab-case")]
188pub struct WrapConfig {
189    /// The wrapping punctuation style.
190    pub punctuation: WrapPunctuation,
191    /// Text inserted after the opening wrap character but before the content.
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub inner_prefix: Option<String>,
194    /// Text inserted after the content but before the closing wrap character.
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub inner_suffix: Option<String>,
197}
198
199impl<'de> serde::Deserialize<'de> for WrapConfig {
200    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
201        struct WrapConfigVisitor;
202
203        impl<'de> serde::de::Visitor<'de> for WrapConfigVisitor {
204            type Value = WrapConfig;
205
206            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
207                write!(
208                    f,
209                    "a wrap punctuation string or a mapping with a 'punctuation' key"
210                )
211            }
212
213            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<WrapConfig, E> {
214                let punctuation = match v {
215                    "parentheses" => WrapPunctuation::Parentheses,
216                    "brackets" => WrapPunctuation::Brackets,
217                    "quotes" => WrapPunctuation::Quotes,
218                    other => {
219                        return Err(E::unknown_variant(
220                            other,
221                            &["parentheses", "brackets", "quotes"],
222                        ));
223                    }
224                };
225                Ok(WrapConfig {
226                    punctuation,
227                    inner_prefix: None,
228                    inner_suffix: None,
229                })
230            }
231
232            fn visit_map<A: serde::de::MapAccess<'de>>(
233                self,
234                mut map: A,
235            ) -> Result<WrapConfig, A::Error> {
236                let mut punctuation: Option<WrapPunctuation> = None;
237                let mut inner_prefix: Option<String> = None;
238                let mut inner_suffix: Option<String> = None;
239
240                while let Some(key) = map.next_key::<String>()? {
241                    match key.as_str() {
242                        "punctuation" => {
243                            punctuation = Some(map.next_value()?);
244                        }
245                        "inner-prefix" => {
246                            inner_prefix = Some(map.next_value()?);
247                        }
248                        "inner-suffix" => {
249                            inner_suffix = Some(map.next_value()?);
250                        }
251                        other => {
252                            return Err(serde::de::Error::unknown_field(
253                                other,
254                                &["punctuation", "inner-prefix", "inner-suffix"],
255                            ));
256                        }
257                    }
258                }
259
260                let punctuation =
261                    punctuation.ok_or_else(|| serde::de::Error::missing_field("punctuation"))?;
262                Ok(WrapConfig {
263                    punctuation,
264                    inner_prefix,
265                    inner_suffix,
266                })
267            }
268        }
269
270        deserializer.deserialize_any(WrapConfigVisitor)
271    }
272}
273
274impl From<WrapPunctuation> for WrapConfig {
275    fn from(punctuation: WrapPunctuation) -> Self {
276        WrapConfig {
277            punctuation,
278            inner_prefix: None,
279            inner_suffix: None,
280        }
281    }
282}
283
284/// Canonical reference type names recognized by the Citum engine.
285///
286/// Used by [`validate_type_name`] to detect likely typos.
287pub const VALID_TYPE_NAMES: &[&str] = &[
288    "book",
289    "manual",
290    "report",
291    "thesis",
292    "webpage",
293    "map",
294    "post",
295    "interview",
296    "manuscript",
297    "personal-communication",
298    "document",
299    "chapter",
300    "entry-dictionary",
301    "paper-conference",
302    "article-journal",
303    "article-magazine",
304    "article-newspaper",
305    "broadcast",
306    "motion-picture",
307    "collection",
308    "legal-case",
309    "statute",
310    "treaty",
311    "hearing",
312    "regulation",
313    "brief",
314    "classic",
315    "patent",
316    "dataset",
317    "standard",
318    "software",
319    // Special keywords
320    "all",
321    "default",
322];
323
324/// Returns `true` if `s` is a recognized reference type name.
325///
326/// Normalizes underscores to hyphens before comparing, so both
327/// `"article_journal"` and `"article-journal"` are accepted.
328/// Returns `false` for unrecognized names (likely typos).
329pub fn validate_type_name(s: &str) -> bool {
330    let normalized = s.replace('_', "-");
331    VALID_TYPE_NAMES.iter().any(|&known| known == normalized)
332}
333
334/// Selector for reference types in overrides.
335/// Can be a single type string or a list of types.
336#[derive(Debug, Clone, PartialEq, Eq, Hash)]
337#[cfg_attr(feature = "schema", derive(JsonSchema))]
338pub enum TypeSelector {
339    Single(String),
340    Multiple(Vec<String>),
341}
342
343impl Serialize for TypeSelector {
344    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
345    where
346        S: serde::Serializer,
347    {
348        serializer.serialize_str(&self.to_string())
349    }
350}
351
352impl<'de> Deserialize<'de> for TypeSelector {
353    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
354    where
355        D: serde::Deserializer<'de>,
356    {
357        struct Visitor;
358        impl<'de> serde::de::Visitor<'de> for Visitor {
359            type Value = TypeSelector;
360
361            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
362                formatter.write_str("a string or a sequence of strings")
363            }
364
365            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
366            where
367                E: serde::de::Error,
368            {
369                v.parse().map_err(E::custom)
370            }
371
372            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
373            where
374                A: serde::de::SeqAccess<'de>,
375            {
376                let mut types = Vec::new();
377                while let Some(t) = seq.next_element::<String>()? {
378                    types.push(t);
379                }
380                if types.len() == 1 {
381                    Ok(TypeSelector::Single(types.remove(0)))
382                } else {
383                    Ok(TypeSelector::Multiple(types))
384                }
385            }
386        }
387        deserializer.deserialize_any(Visitor)
388    }
389}
390
391impl std::fmt::Display for TypeSelector {
392    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
393        match self {
394            TypeSelector::Single(s) => write!(f, "{s}"),
395            TypeSelector::Multiple(types) => write!(f, "{}", types.join(",")),
396        }
397    }
398}
399
400impl std::str::FromStr for TypeSelector {
401    type Err = std::convert::Infallible;
402
403    fn from_str(s: &str) -> Result<Self, Self::Err> {
404        if s.contains(',') {
405            Ok(TypeSelector::Multiple(
406                s.split(',').map(|t| t.trim().to_string()).collect(),
407            ))
408        } else {
409            Ok(TypeSelector::Single(s.to_string()))
410        }
411    }
412}
413
414impl TypeSelector {
415    /// Check whether this selector matches a reference type.
416    ///
417    /// Type names are compared after normalizing underscores to hyphens, so
418    /// "legal_case" and "legal-case" are treated as equivalent (matching both
419    /// CSL 1.0 underscore convention and Citum hyphen convention).
420    ///
421    /// The special keyword "all" always matches any reference type.
422    pub fn matches(&self, ref_type: &str) -> bool {
423        let normalized_ref = ref_type.replace('_', "-");
424        let base_ref = normalized_ref
425            .split_once('+')
426            .map(|(base, _)| base)
427            .unwrap_or(&normalized_ref);
428        let eq = |s: &str| -> bool {
429            s == ref_type
430                || s.replace('_', "-") == normalized_ref
431                || s.replace('_', "-") == base_ref
432                || s == "all"
433                || (s == "default" && ref_type == "default")
434        };
435        match self {
436            TypeSelector::Single(s) => eq(s),
437            TypeSelector::Multiple(types) => types.iter().any(|t| eq(t)),
438        }
439    }
440
441    /// Returns any type names in this selector that are not in [`VALID_TYPE_NAMES`].
442    ///
443    /// An empty vec means all names are valid. Callers should emit a
444    /// [`crate::SchemaWarning`] for each returned name.
445    pub fn unknown_type_names(&self) -> Vec<&str> {
446        match self {
447            TypeSelector::Single(s) => {
448                if validate_type_name(s) {
449                    vec![]
450                } else {
451                    vec![s.as_str()]
452                }
453            }
454            TypeSelector::Multiple(types) => types
455                .iter()
456                .filter(|s| !validate_type_name(s))
457                .map(|s| s.as_str())
458                .collect(),
459        }
460    }
461}
462
463/// A template component - the building blocks of citation/bibliography templates.
464///
465/// Each variant handles a specific data type with appropriate formatting options.
466#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
467#[cfg_attr(feature = "schema", derive(JsonSchema))]
468#[serde(untagged)]
469#[non_exhaustive]
470pub enum TemplateComponent {
471    Contributor(TemplateContributor),
472    Date(TemplateDate),
473    Title(TemplateTitle),
474    Number(TemplateNumber),
475    Variable(TemplateVariable),
476    Message(TemplateMessage),
477    Group(TemplateGroup),
478    Term(TemplateTerm),
479    TypeLabel(TemplateTypeLabel),
480}
481
482impl Default for TemplateComponent {
483    fn default() -> Self {
484        TemplateComponent::Variable(TemplateVariable::default())
485    }
486}
487
488impl TemplateComponent {
489    /// Return the rendering options for this component.
490    ///
491    /// Every template component has rendering options like emphasis, wrapping, and prefixes.
492    pub fn rendering(&self) -> &Rendering {
493        crate::dispatch_component!(self, |inner| &inner.rendering)
494    }
495
496    /// Return the mutable rendering options for this component.
497    ///
498    /// Provides mutable access to rendering fields (prefix, suffix, etc.)
499    /// that are present on all template component variants.
500    pub fn rendering_mut(&mut self) -> &mut Rendering {
501        crate::dispatch_component!(self, |inner| &mut inner.rendering)
502    }
503}
504
505/// Type-specific template override, either as a complete legacy template or a V3 diff.
506#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
507#[cfg_attr(feature = "schema", derive(JsonSchema))]
508#[serde(untagged)]
509pub enum TemplateVariant {
510    /// Complete replacement template used by Template V1/V2 styles.
511    Full(Vec<TemplateComponent>),
512    /// Structural diff applied to a parent template during style resolution.
513    Diff(TemplateVariantDiff),
514}
515
516impl TemplateVariant {
517    /// Return this variant as a concrete template if it has already been resolved.
518    #[must_use]
519    pub fn as_template(&self) -> Option<&[TemplateComponent]> {
520        match self {
521            Self::Full(template) => Some(template.as_slice()),
522            Self::Diff(_) => None,
523        }
524    }
525
526    /// Return this variant as a mutable concrete template if it has already been resolved.
527    pub fn as_template_mut(&mut self) -> Option<&mut Vec<TemplateComponent>> {
528        match self {
529            Self::Full(template) => Some(template),
530            Self::Diff(_) => None,
531        }
532    }
533
534    /// Convert this variant into its concrete template if it has already been resolved.
535    #[must_use]
536    pub fn into_template(self) -> Option<Vec<TemplateComponent>> {
537        match self {
538            Self::Full(template) => Some(template),
539            Self::Diff(_) => None,
540        }
541    }
542}
543
544impl From<Vec<TemplateComponent>> for TemplateVariant {
545    fn from(template: Vec<TemplateComponent>) -> Self {
546        Self::Full(template)
547    }
548}
549
550/// Structural diff that derives a type-specific template from a parent template.
551#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
552#[cfg_attr(feature = "schema", derive(JsonSchema))]
553#[serde(rename_all = "kebab-case", deny_unknown_fields)]
554pub struct TemplateVariantDiff {
555    /// Optional parent type variant selector within the same section.
556    #[serde(skip_serializing_if = "Option::is_none")]
557    pub extends: Option<TypeSelector>,
558    /// Rendering-only modifications applied in authored order.
559    #[serde(skip_serializing_if = "Vec::is_empty", default)]
560    pub modify: Vec<TemplateModifyOperation>,
561    /// Component removals applied in authored order.
562    #[serde(skip_serializing_if = "Vec::is_empty", default)]
563    pub remove: Vec<TemplateRemoveOperation>,
564    /// Component additions applied in authored order.
565    #[serde(skip_serializing_if = "Vec::is_empty", default)]
566    pub add: Vec<TemplateAddOperation>,
567}
568
569/// Partial component selector used to locate anchors in a template.
570#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
571#[cfg_attr(feature = "schema", derive(JsonSchema))]
572#[serde(transparent)]
573pub struct TemplateComponentSelector {
574    /// Component fields that must be present with equal values on the target component.
575    pub fields: BTreeMap<String, serde_json::Value>,
576}
577
578impl TemplateComponentSelector {
579    /// Returns `true` when this selector has no fields.
580    #[must_use]
581    pub fn is_empty(&self) -> bool {
582        self.fields.is_empty()
583    }
584
585    /// Returns `true` when every selector field is present with the same value.
586    #[must_use]
587    pub fn matches(&self, component: &TemplateComponent) -> bool {
588        let Ok(serde_json::Value::Object(component_fields)) = serde_json::to_value(component)
589        else {
590            return false;
591        };
592
593        self.fields.iter().all(|(key, expected)| {
594            component_fields
595                .get(key)
596                .is_some_and(|actual| selector_value_matches(expected, actual))
597        })
598    }
599}
600
601fn selector_value_matches(expected: &serde_json::Value, actual: &serde_json::Value) -> bool {
602    match (expected, actual) {
603        (serde_json::Value::Object(expected_fields), serde_json::Value::Object(actual_fields)) => {
604            expected_fields.iter().all(|(key, expected_value)| {
605                actual_fields.get(key).is_some_and(|actual_value| {
606                    selector_value_matches(expected_value, actual_value)
607                })
608            })
609        }
610        (serde_json::Value::Array(expected_items), serde_json::Value::Array(actual_items)) => {
611            expected_items.len() == actual_items.len()
612                && expected_items.iter().zip(actual_items.iter()).all(
613                    |(expected_item, actual_item)| {
614                        selector_value_matches(expected_item, actual_item)
615                    },
616                )
617        }
618        _ => expected == actual,
619    }
620}
621
622/// Rendering-only modification for the component matched by `match`.
623#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
624#[cfg_attr(feature = "schema", derive(JsonSchema))]
625#[serde(rename_all = "kebab-case", deny_unknown_fields)]
626pub struct TemplateModifyOperation {
627    /// Selector identifying exactly one component to modify.
628    #[serde(rename = "match")]
629    pub match_selector: TemplateComponentSelector,
630    /// Override the localized number label form when modifying number components.
631    #[serde(skip_serializing_if = "Option::is_none")]
632    pub label_form: Option<LabelForm>,
633    /// Rendering fields to merge onto the matched component.
634    #[serde(flatten, default)]
635    pub rendering: Rendering,
636}
637
638/// Removal operation for the component matched by `match`.
639#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
640#[cfg_attr(feature = "schema", derive(JsonSchema))]
641#[serde(rename_all = "kebab-case", deny_unknown_fields)]
642pub struct TemplateRemoveOperation {
643    /// Selector identifying exactly one component to remove.
644    #[serde(rename = "match")]
645    pub match_selector: TemplateComponentSelector,
646}
647
648/// Addition operation that inserts a component before or after an anchor.
649#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
650#[cfg_attr(feature = "schema", derive(JsonSchema))]
651#[serde(rename_all = "kebab-case", deny_unknown_fields)]
652pub struct TemplateAddOperation {
653    /// Anchor selector before which the component should be inserted.
654    #[serde(skip_serializing_if = "Option::is_none")]
655    pub before: Option<TemplateComponentSelector>,
656    /// Anchor selector after which the component should be inserted.
657    #[serde(skip_serializing_if = "Option::is_none")]
658    pub after: Option<TemplateComponentSelector>,
659    /// Component to insert.
660    pub component: TemplateComponent,
661}
662
663/// Configuration for role labels (e.g., "eds.", "trans.").
664#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
665#[cfg_attr(feature = "schema", derive(JsonSchema))]
666#[serde(rename_all = "kebab-case")]
667pub struct RoleLabel {
668    /// Locale term key for the role (e.g., "editor", "translator").
669    pub term: String,
670    /// Term form: short ("eds.") or long ("editors").
671    #[serde(default)]
672    pub form: RoleLabelForm,
673    /// Where to place the label relative to names.
674    #[serde(default)]
675    pub placement: LabelPlacement,
676    /// Optional case transform applied to the resolved label term, e.g.
677    /// `capitalize-first` renders "Eds." from the locale's "eds." (as IEEE
678    /// requires). When unset the term is rendered as the locale stores it.
679    #[serde(default, skip_serializing_if = "Option::is_none")]
680    pub text_case: Option<crate::options::titles::TextCase>,
681    /// Optional affix rendered before the label term, overriding the
682    /// placement-derived default (`", "` for suffix placement, empty for
683    /// prefix placement). Mirrors CSL 1.0 `cs:label` `prefix` (e.g. `" ("`
684    /// for elsevier's `" (Eds.)"`).
685    #[serde(default, skip_serializing_if = "Option::is_none")]
686    pub prefix: Option<String>,
687    /// Optional affix rendered after the label term, overriding the
688    /// placement-derived default (empty for suffix placement, `" "` for
689    /// prefix placement). Mirrors CSL 1.0 `cs:label` `suffix` (e.g. `")"`).
690    #[serde(default, skip_serializing_if = "Option::is_none")]
691    pub suffix: Option<String>,
692}
693
694/// Term form for role labels.
695#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
696#[cfg_attr(feature = "schema", derive(JsonSchema))]
697#[serde(rename_all = "kebab-case")]
698pub enum RoleLabelForm {
699    #[default]
700    Short,
701    Long,
702}
703
704/// Label placement relative to contributor names.
705#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
706#[cfg_attr(feature = "schema", derive(JsonSchema))]
707#[serde(rename_all = "kebab-case")]
708pub enum LabelPlacement {
709    Prefix,
710    #[default]
711    Suffix,
712}
713
714/// A contributor component for rendering names.
715#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
716#[cfg_attr(feature = "schema", derive(JsonSchema))]
717#[serde(rename_all = "kebab-case", deny_unknown_fields)]
718pub struct TemplateContributor {
719    /// Which contributor role to render (author, editor, etc.).
720    pub contributor: ContributorRole,
721    /// How to display the contributor (long names, short, with label, etc.).
722    pub form: ContributorForm,
723    /// Optional role label configuration (e.g., "eds." for editors).
724    #[serde(skip_serializing_if = "Option::is_none")]
725    pub label: Option<RoleLabel>,
726    /// Override the global name order for this specific component.
727    /// Use to show editors as "Given Family" even when global setting is "Family, Given".
728    #[serde(skip_serializing_if = "Option::is_none")]
729    pub name_order: Option<NameOrder>,
730    /// Override the name form (e.g., initials, full, family-only) for this specific component.
731    #[serde(skip_serializing_if = "Option::is_none", rename = "name-form")]
732    pub name_form: Option<crate::options::contributors::NameForm>,
733    /// Custom delimiter between names (overrides global setting).
734    #[serde(skip_serializing_if = "Option::is_none")]
735    pub delimiter: Option<String>,
736    /// Delimiter between family and given name when inverted (overrides global setting).
737    #[serde(skip_serializing_if = "Option::is_none")]
738    pub sort_separator: Option<String>,
739    /// Shorten the list of names (et al. configuration).
740    #[serde(skip_serializing_if = "Option::is_none")]
741    pub shorten: Option<crate::options::ShortenListOptions>,
742    /// Override the conjunction between the last two names.
743    /// Use `none` for bibliography when citation uses `text` or `symbol`.
744    #[serde(skip_serializing_if = "Option::is_none")]
745    pub and: Option<crate::options::AndOptions>,
746    #[serde(flatten, default)]
747    pub rendering: Rendering,
748    /// Structured link options (DOI, URL).
749    #[serde(skip_serializing_if = "Option::is_none")]
750    pub links: Option<crate::options::LinksConfig>,
751    /// Explicit grammatical gender override for role-label agreement.
752    #[serde(skip_serializing_if = "Option::is_none")]
753    pub gender: Option<GrammaticalGender>,
754
755    /// Custom user-defined fields for extensions.
756    #[serde(skip_serializing_if = "Option::is_none")]
757    pub custom: Option<HashMap<String, serde_json::Value>>,
758}
759
760/// Name display order.
761#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
762#[cfg_attr(feature = "schema", derive(JsonSchema))]
763#[serde(rename_all = "kebab-case")]
764pub enum NameOrder {
765    /// Display as "Given Family" (e.g., "John Smith").
766    GivenFirst,
767    /// Display as "Family, Given" (e.g., "Smith, John").
768    #[default]
769    FamilyFirst,
770    /// First contributor inverted ("Family, Given"); subsequent contributors given-first.
771    FamilyFirstOnly,
772}
773
774/// How to render contributor names.
775#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
776#[cfg_attr(feature = "schema", derive(JsonSchema))]
777#[serde(rename_all = "kebab-case")]
778pub enum ContributorForm {
779    #[default]
780    Long,
781    Short,
782    FamilyOnly,
783    Verb,
784    VerbShort,
785}
786
787crate::str_enum! {
788    /// Contributor roles.
789    #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
790    pub enum ContributorRole {
791        #[default] Author = "author",
792        Chair = "chair",
793        Editor = "editor",
794        Translator = "translator",
795        Director = "director",
796        Publisher = "publisher",
797        Recipient = "recipient",
798        Interviewer = "interviewer",
799        Interviewee = "interviewee",
800        Guest = "guest",
801        Performer = "performer",
802        Inventor = "inventor",
803        Counsel = "counsel",
804        Composer = "composer",
805        Writer = "writer",
806        CollectionEditor = "collection-editor",
807        ContainerAuthor = "container-author",
808        EditorialDirector = "editorial-director",
809        TextualEditor = "textual-editor",
810        Illustrator = "illustrator",
811        OriginalAuthor = "original-author",
812        ReviewedAuthor = "reviewed-author"
813    }
814}
815
816/// A date component for rendering dates.
817#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
818#[cfg_attr(feature = "schema", derive(JsonSchema))]
819#[serde(rename_all = "kebab-case", deny_unknown_fields)]
820pub struct TemplateDate {
821    pub date: DateVariable,
822    pub form: DateForm,
823    /// Fallback components if the primary date is missing.
824    #[serde(skip_serializing_if = "Option::is_none")]
825    pub fallback: Option<Vec<TemplateComponent>>,
826    #[serde(flatten, default)]
827    pub rendering: Rendering,
828    /// Structured link options (DOI, URL).
829    #[serde(skip_serializing_if = "Option::is_none")]
830    pub links: Option<crate::options::LinksConfig>,
831
832    /// Custom user-defined fields for extensions.
833    #[serde(skip_serializing_if = "Option::is_none")]
834    pub custom: Option<HashMap<String, serde_json::Value>>,
835}
836
837/// Date variables.
838#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
839#[cfg_attr(feature = "schema", derive(JsonSchema))]
840#[serde(rename_all = "kebab-case")]
841pub enum DateVariable {
842    #[default]
843    Issued,
844    Accessed,
845    OriginalPublished,
846    Submitted,
847    EventDate,
848}
849
850crate::str_enum! {
851    /// Date rendering forms.
852    #[derive(Debug, Default, Clone, PartialEq)]
853    pub enum DateForm {
854        #[default]
855        Year = "year",
856        YearMonth = "year-month",
857        /// Month name only, no year or day: "June" (e.g. magazines whose year
858        /// is already supplied by the author-date position).
859        Month = "month",
860        Full = "full",
861        MonthDay = "month-day",
862        YearMonthDay = "year-month-day",
863        DayMonthAbbrYear = "day-month-abbr-year",
864        /// Abbreviated month + day + year in US order: "Jan 15, 2024".
865        MonthAbbrDayYear = "month-abbr-day-year"
866    }
867}
868
869/// A title component.
870#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
871#[cfg_attr(feature = "schema", derive(JsonSchema))]
872#[serde(rename_all = "kebab-case", deny_unknown_fields)]
873pub struct TemplateTitle {
874    pub title: TitleType,
875    #[serde(skip_serializing_if = "Option::is_none")]
876    pub form: Option<TitleForm>,
877    /// When true, suppress this title component unless the reference needs
878    /// disambiguation (i.e. multiple works by the same author appear in the
879    /// document). Used by author-class styles (e.g. MLA) where the title
880    /// appears in citations only to resolve same-author ambiguity.
881    #[serde(skip_serializing_if = "Option::is_none")]
882    pub disambiguate_only: Option<bool>,
883    #[serde(flatten, default)]
884    pub rendering: Rendering,
885    /// Structured link options (DOI, URL).
886    #[serde(skip_serializing_if = "Option::is_none")]
887    pub links: Option<crate::options::LinksConfig>,
888
889    /// Custom user-defined fields for extensions.
890    #[serde(skip_serializing_if = "Option::is_none")]
891    pub custom: Option<HashMap<String, serde_json::Value>>,
892}
893
894/// Types of titles.
895#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
896#[cfg_attr(feature = "schema", derive(JsonSchema))]
897#[serde(rename_all = "kebab-case")]
898#[non_exhaustive]
899pub enum TitleType {
900    /// The primary title of the cited work.
901    #[default]
902    Primary,
903    /// Title of the parent work containing the cited work.
904    ContainerTitle,
905    /// Title of a book/monograph containing the cited work.
906    ParentMonograph,
907    /// Title of a periodical/serial containing the cited work.
908    ParentSerial,
909    /// Title of a series or collection containing the cited work.
910    CollectionTitle,
911    /// Title of the work's original publication (e.g. a translation's source-language title).
912    Original,
913}
914
915/// Title rendering forms.
916#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
917#[cfg_attr(feature = "schema", derive(JsonSchema))]
918#[serde(rename_all = "kebab-case")]
919pub enum TitleForm {
920    Short,
921    #[default]
922    Long,
923}
924
925/// A number component (volume, issue, pages, etc.).
926#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
927#[cfg_attr(feature = "schema", derive(JsonSchema))]
928#[serde(rename_all = "kebab-case", deny_unknown_fields)]
929pub struct TemplateNumber {
930    pub number: NumberVariable,
931    #[serde(skip_serializing_if = "Option::is_none")]
932    pub form: Option<NumberForm>,
933    #[serde(skip_serializing_if = "Option::is_none")]
934    pub label_form: Option<LabelForm>,
935    /// When `true`, show this pages component even when a locator is present in a note-style citation.
936    /// By default, pages are suppressed in note-style citations when a locator is present.
937    #[serde(skip_serializing_if = "Option::is_none")]
938    pub show_with_locator: Option<bool>,
939    #[serde(flatten)]
940    pub rendering: Rendering,
941    /// Structured link options (DOI, URL).
942    #[serde(skip_serializing_if = "Option::is_none")]
943    pub links: Option<crate::options::LinksConfig>,
944    /// Explicit grammatical gender override for number/ordinal agreement.
945    #[serde(skip_serializing_if = "Option::is_none")]
946    pub gender: Option<GrammaticalGender>,
947
948    /// Custom user-defined fields for extensions.
949    #[serde(skip_serializing_if = "Option::is_none")]
950    pub custom: Option<HashMap<String, serde_json::Value>>,
951}
952
953/// Number variables.
954///
955/// Use `number:` when the value is treated as a number by the style:
956/// numeric labels, numeric-specific formatting, ordinals, roman numerals, or
957/// locator-aware punctuation. Use `variable:` instead when the field should be
958/// passed through as plain text without number formatting semantics.
959#[derive(Debug, Default, Clone)]
960#[non_exhaustive]
961pub enum NumberVariable {
962    #[default]
963    Volume,
964    Issue,
965    Pages,
966    Edition,
967    ChapterNumber,
968    CollectionNumber,
969    NumberOfPages,
970    NumberOfVolumes,
971    CitationNumber,
972    /// First-occurrence note number for the cited reference (note styles only).
973    /// Populated from the document processor; omitted (not rendered) when the
974    /// citation is not in a subsequent position or no first-note number is available.
975    FirstReferenceNoteNumber,
976    CitationLabel,
977    Number,
978    DocketNumber,
979    PatentNumber,
980    StandardNumber,
981    ReportNumber,
982    PartNumber,
983    SupplementNumber,
984    PrintingNumber,
985    /// A custom numbering variable rendered from an arbitrary numbering kind.
986    Custom(String),
987}
988
989impl NumberVariable {
990    /// Return the canonical kebab-case key for this numeric variable.
991    #[must_use]
992    pub fn as_key(&self) -> Cow<'_, str> {
993        match self {
994            Self::Volume => Cow::Borrowed("volume"),
995            Self::Issue => Cow::Borrowed("issue"),
996            Self::Pages => Cow::Borrowed("pages"),
997            Self::Edition => Cow::Borrowed("edition"),
998            Self::ChapterNumber => Cow::Borrowed("chapter-number"),
999            Self::CollectionNumber => Cow::Borrowed("collection-number"),
1000            Self::NumberOfPages => Cow::Borrowed("number-of-pages"),
1001            Self::NumberOfVolumes => Cow::Borrowed("number-of-volumes"),
1002            Self::CitationNumber => Cow::Borrowed("citation-number"),
1003            Self::FirstReferenceNoteNumber => Cow::Borrowed("first-reference-note-number"),
1004            Self::CitationLabel => Cow::Borrowed("citation-label"),
1005            Self::Number => Cow::Borrowed("number"),
1006            Self::DocketNumber => Cow::Borrowed("docket-number"),
1007            Self::PatentNumber => Cow::Borrowed("patent-number"),
1008            Self::StandardNumber => Cow::Borrowed("standard-number"),
1009            Self::ReportNumber => Cow::Borrowed("report-number"),
1010            Self::PartNumber => Cow::Borrowed("part-number"),
1011            Self::SupplementNumber => Cow::Borrowed("supplement-number"),
1012            Self::PrintingNumber => Cow::Borrowed("printing-number"),
1013            Self::Custom(value) => normalize_kind_key(value)
1014                .map(Cow::Owned)
1015                .unwrap_or_else(|| Cow::Borrowed(value.as_str())),
1016        }
1017    }
1018
1019    fn from_key(value: &str) -> Result<Self, String> {
1020        let canonical = normalize_kind_key(value)
1021            .ok_or_else(|| "number variable must not be empty".to_string())?;
1022        Ok(match canonical.as_str() {
1023            "volume" => Self::Volume,
1024            "issue" => Self::Issue,
1025            "pages" => Self::Pages,
1026            "edition" => Self::Edition,
1027            "chapter-number" => Self::ChapterNumber,
1028            "collection-number" => Self::CollectionNumber,
1029            "number-of-pages" => Self::NumberOfPages,
1030            "number-of-volumes" => Self::NumberOfVolumes,
1031            "citation-number" => Self::CitationNumber,
1032            "first-reference-note-number" => Self::FirstReferenceNoteNumber,
1033            "citation-label" => Self::CitationLabel,
1034            "number" => Self::Number,
1035            "docket-number" => Self::DocketNumber,
1036            "patent-number" => Self::PatentNumber,
1037            "standard-number" => Self::StandardNumber,
1038            "report-number" => Self::ReportNumber,
1039            "part-number" => Self::PartNumber,
1040            "supplement-number" => Self::SupplementNumber,
1041            "printing-number" => Self::PrintingNumber,
1042            _ => Self::Custom(canonical),
1043        })
1044    }
1045}
1046
1047impl PartialEq for NumberVariable {
1048    fn eq(&self, other: &Self) -> bool {
1049        self.as_key().as_ref() == other.as_key().as_ref()
1050    }
1051}
1052
1053impl Eq for NumberVariable {}
1054
1055impl Hash for NumberVariable {
1056    fn hash<H: Hasher>(&self, state: &mut H) {
1057        self.as_key().as_ref().hash(state);
1058    }
1059}
1060
1061impl Serialize for NumberVariable {
1062    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1063    where
1064        S: Serializer,
1065    {
1066        serializer.serialize_str(self.as_key().as_ref())
1067    }
1068}
1069
1070impl<'de> Deserialize<'de> for NumberVariable {
1071    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1072    where
1073        D: Deserializer<'de>,
1074    {
1075        let value = String::deserialize(deserializer)?;
1076        Self::from_key(&value).map_err(serde::de::Error::custom)
1077    }
1078}
1079
1080#[cfg(feature = "schema")]
1081impl JsonSchema for NumberVariable {
1082    fn schema_name() -> std::borrow::Cow<'static, str> {
1083        "NumberVariable".into()
1084    }
1085
1086    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1087        schemars::json_schema!({
1088            "type": "string",
1089            "description": "Known number variable keyword or custom kebab-case identifier."
1090        })
1091    }
1092}
1093
1094/// Number rendering forms.
1095#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1096#[cfg_attr(feature = "schema", derive(JsonSchema))]
1097#[serde(rename_all = "lowercase")]
1098pub enum NumberForm {
1099    #[default]
1100    Numeric,
1101    Ordinal,
1102    Roman,
1103}
1104
1105fn normalize_kind_key(value: &str) -> Option<String> {
1106    let mut normalized = String::new();
1107    let mut pending_dash = false;
1108
1109    for ch in value.trim().chars() {
1110        if ch.is_ascii_alphanumeric() {
1111            if pending_dash && !normalized.is_empty() {
1112                normalized.push('-');
1113            }
1114            normalized.push(ch.to_ascii_lowercase());
1115            pending_dash = false;
1116        } else if !normalized.is_empty() {
1117            pending_dash = true;
1118        }
1119    }
1120
1121    if normalized.is_empty() {
1122        None
1123    } else {
1124        Some(normalized)
1125    }
1126}
1127
1128/// Label rendering forms.
1129#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1130#[cfg_attr(feature = "schema", derive(JsonSchema))]
1131#[serde(rename_all = "kebab-case")]
1132pub enum LabelForm {
1133    Long,
1134    #[default]
1135    Short,
1136    Symbol,
1137}
1138
1139/// A simple variable component (DOI, ISBN, URL, etc.).
1140#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1141#[cfg_attr(feature = "schema", derive(JsonSchema))]
1142#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1143pub struct TemplateVariable {
1144    pub variable: SimpleVariable,
1145    #[serde(flatten)]
1146    pub rendering: Rendering,
1147    /// Structured link options (DOI, URL).
1148    #[serde(skip_serializing_if = "Option::is_none")]
1149    pub links: Option<crate::options::LinksConfig>,
1150
1151    /// Custom user-defined fields for extensions.
1152    #[serde(skip_serializing_if = "Option::is_none")]
1153    pub custom: Option<HashMap<String, serde_json::Value>>,
1154}
1155
1156/// A locale message call inside a citation or bibliography template.
1157///
1158/// The style chooses the message ID and supplies structured argument sources;
1159/// the active locale owns the natural-language realization in its `messages`
1160/// map.
1161#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1162#[cfg_attr(feature = "schema", derive(JsonSchema))]
1163#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1164pub struct TemplateMessage {
1165    /// Locale message ID to evaluate, such as `pattern.accessed-date`.
1166    pub message: String,
1167    /// Optional term form used when `message` addresses a `term.*` locale item.
1168    #[serde(skip_serializing_if = "Option::is_none")]
1169    pub form: Option<TermForm>,
1170    /// Explicit grammatical gender override for term-backed message selection.
1171    #[serde(skip_serializing_if = "Option::is_none")]
1172    pub gender: Option<GrammaticalGender>,
1173    /// Named argument sources pre-rendered before message evaluation.
1174    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1175    pub args: HashMap<String, MessageArgSource>,
1176    #[serde(flatten, default)]
1177    pub rendering: Rendering,
1178
1179    /// Custom user-defined fields for extensions.
1180    #[serde(skip_serializing_if = "Option::is_none")]
1181    pub custom: Option<HashMap<String, serde_json::Value>>,
1182}
1183
1184/// A structured source for one named locale-message argument.
1185#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1186#[cfg_attr(feature = "schema", derive(JsonSchema))]
1187#[serde(untagged)]
1188pub enum MessageArgSource {
1189    /// A literal string argument.
1190    Literal { literal: String },
1191    /// A rendered contributor argument.
1192    Contributor(TemplateContributor),
1193    /// A rendered date argument.
1194    Date(TemplateDate),
1195    /// A rendered group argument.
1196    Group(TemplateGroup),
1197    /// A rendered title argument.
1198    Title(TemplateTitle),
1199    /// A rendered number argument.
1200    Number(TemplateNumber),
1201    /// A rendered variable argument.
1202    Variable(TemplateVariable),
1203    /// A rendered locale term argument.
1204    Term(TemplateTerm),
1205}
1206
1207impl MessageArgSource {
1208    /// Convert this argument source into a normal template component when it
1209    /// should be rendered through the standard component pipeline.
1210    #[must_use]
1211    pub fn as_template_component(&self) -> Option<TemplateComponent> {
1212        match self {
1213            Self::Literal { .. } => None,
1214            Self::Contributor(component) => Some(TemplateComponent::Contributor(component.clone())),
1215            Self::Date(component) => Some(TemplateComponent::Date(component.clone())),
1216            Self::Group(component) => Some(TemplateComponent::Group(component.clone())),
1217            Self::Title(component) => Some(TemplateComponent::Title(component.clone())),
1218            Self::Number(component) => Some(TemplateComponent::Number(component.clone())),
1219            Self::Variable(component) => Some(TemplateComponent::Variable(component.clone())),
1220            Self::Term(component) => Some(TemplateComponent::Term(component.clone())),
1221        }
1222    }
1223}
1224
1225/// Simple string variables.
1226///
1227/// Use `variable:` for string passthrough fields, even when the field name is
1228/// also present in [`NumberVariable`]. For example, `variable: volume` keeps the
1229/// source value as plain text, while `number: volume` opts into numeric
1230/// formatting behavior.
1231#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1232#[cfg_attr(feature = "schema", derive(JsonSchema))]
1233#[serde(rename_all = "kebab-case")]
1234#[non_exhaustive]
1235pub enum SimpleVariable {
1236    #[default]
1237    Doi,
1238    Isbn,
1239    Issn,
1240    Url,
1241    Pmid,
1242    Pmcid,
1243    Abstract,
1244    Note,
1245    Annote,
1246    Keyword,
1247    Genre,
1248    RawGenre,
1249    Medium,
1250    RawMedium,
1251    Source,
1252    Status,
1253    Archive,
1254    ArchiveLocation,
1255    ArchiveName,
1256    ArchivePlace,
1257    ArchiveCollection,
1258    ArchiveCollectionId,
1259    ArchiveSeries,
1260    ArchiveBox,
1261    ArchiveFolder,
1262    ArchiveItem,
1263    ArchiveUrl,
1264    EprintId,
1265    EprintServer,
1266    EprintClass,
1267    Publisher,
1268    PublisherPlace,
1269    OriginalPublisher,
1270    OriginalPublisherPlace,
1271    EventTitle,
1272    EventPlace,
1273    Dimensions,
1274    References,
1275    Scale,
1276    Version,
1277    Locator,
1278    ContainerTitleShort,
1279    Authority,
1280    Code,
1281    Reporter,
1282    Page,
1283    Section,
1284    Volume,
1285    Number,
1286    DocketNumber,
1287    PatentNumber,
1288    StandardNumber,
1289    ReportNumber,
1290    AdsBibcode,
1291}
1292
1293/// A term component for rendering locale-specific text.
1294#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1295#[cfg_attr(feature = "schema", derive(JsonSchema))]
1296#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1297pub struct TemplateTerm {
1298    /// Which term to render.
1299    pub term: GeneralTerm,
1300    /// Form: long (default), short, or symbol.
1301    #[serde(skip_serializing_if = "Option::is_none")]
1302    pub form: Option<TermForm>,
1303    /// Explicit grammatical gender override for term selection.
1304    #[serde(skip_serializing_if = "Option::is_none")]
1305    pub gender: Option<GrammaticalGender>,
1306    #[serde(flatten, default)]
1307    pub rendering: Rendering,
1308
1309    /// Custom user-defined fields for extensions.
1310    #[serde(skip_serializing_if = "Option::is_none")]
1311    pub custom: Option<HashMap<String, serde_json::Value>>,
1312}
1313
1314/// Where a [`TemplateTypeLabel`] resolves its text from.
1315///
1316/// `#[non_exhaustive]` with a single variant today: the label always
1317/// describes the reference's own type. Kept as an enum (rather than a bare
1318/// marker field) so a future label source can be added without a schema
1319/// break.
1320#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
1321#[cfg_attr(feature = "schema", derive(JsonSchema))]
1322#[serde(rename_all = "kebab-case")]
1323#[non_exhaustive]
1324pub enum TypeLabelSource {
1325    /// Resolve the label from the reference's own type: prefer its
1326    /// `genre`/`medium`, falling back to a locale term keyed by `ref_type`.
1327    #[default]
1328    ReferenceType,
1329}
1330
1331/// A localized label describing the reference's own type (e.g. "Dataset",
1332/// "Classical work"), resolved from `genre`/`medium` with a locale-term
1333/// fallback keyed by `ref_type`.
1334///
1335/// Emits only the resolved term text — wrap it in `wrap: brackets` (or any
1336/// other `Rendering` option) at the style level to match a particular
1337/// style's presentation, the same as any other component.
1338///
1339/// See `docs/specs/TYPE_CLASSIFICATION_CENTRALIZATION.md`.
1340#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1341#[cfg_attr(feature = "schema", derive(JsonSchema))]
1342#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1343pub struct TemplateTypeLabel {
1344    /// The label's text source. Currently always `reference-type`.
1345    #[serde(rename = "type-label")]
1346    pub type_label: TypeLabelSource,
1347    #[serde(flatten, default)]
1348    pub rendering: Rendering,
1349
1350    /// Custom user-defined fields for extensions.
1351    #[serde(skip_serializing_if = "Option::is_none")]
1352    pub custom: Option<HashMap<String, serde_json::Value>>,
1353}
1354
1355/// A group component for grouping multiple components with a delimiter,
1356/// matching CSL 1.0 `<group>` semantics.
1357#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1358#[cfg_attr(feature = "schema", derive(JsonSchema))]
1359#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1360pub struct TemplateGroup {
1361    pub group: Vec<TemplateComponent>,
1362    /// Optional field-presence condition that controls whether the group renders.
1363    #[serde(skip_serializing_if = "Option::is_none")]
1364    pub render_when: Option<TemplateGroupCondition>,
1365    #[serde(skip_serializing_if = "Option::is_none")]
1366    pub delimiter: Option<DelimiterPunctuation>,
1367    #[serde(flatten, default)]
1368    pub rendering: Rendering,
1369
1370    /// Custom user-defined fields for extensions.
1371    #[serde(skip_serializing_if = "Option::is_none")]
1372    pub custom: Option<HashMap<String, serde_json::Value>>,
1373}
1374
1375/// Field-presence condition for rendering a template group.
1376#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1377#[cfg_attr(feature = "schema", derive(JsonSchema))]
1378#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1379pub struct TemplateGroupCondition {
1380    /// Required field that must be present for the group to render.
1381    #[serde(skip_serializing_if = "Option::is_none")]
1382    pub field_present: Option<TemplateConditionField>,
1383    /// Required field that must be absent for the group to render.
1384    #[serde(skip_serializing_if = "Option::is_none")]
1385    pub field_absent: Option<TemplateConditionField>,
1386}
1387
1388/// Reference fields that can be tested by a template group condition.
1389#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
1390#[cfg_attr(feature = "schema", derive(JsonSchema))]
1391#[serde(rename_all = "kebab-case")]
1392pub enum TemplateConditionField {
1393    /// The primary author contributor.
1394    Author,
1395    /// The editor contributor.
1396    Editor,
1397    /// The recipient contributor.
1398    Recipient,
1399    /// The translator contributor.
1400    Translator,
1401    /// The primary title.
1402    Title,
1403    /// The series or collection title.
1404    CollectionTitle,
1405    /// The issued date.
1406    Issued,
1407    /// The original publication date.
1408    OriginalPublished,
1409    /// The publisher name.
1410    Publisher,
1411    /// The original publisher name (e.g. a reprint's first publisher).
1412    OriginalPublisher,
1413    /// The original publisher place (e.g. a reprint's first place of publication).
1414    OriginalPublisherPlace,
1415    /// The original title (e.g. a translation's title in its source language).
1416    OriginalTitle,
1417    /// The DOI identifier.
1418    Doi,
1419    /// The reference genre or item type label.
1420    Genre,
1421    /// The archive or repository name.
1422    Archive,
1423    /// The archive shelfmark or repository location.
1424    ArchiveLocation,
1425}
1426
1427/// Delimiter punctuation options.
1428#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)]
1429#[serde(rename_all = "kebab-case")]
1430pub enum DelimiterPunctuation {
1431    #[default]
1432    Comma,
1433    Semicolon,
1434    Period,
1435    Colon,
1436    Ampersand,
1437    VerticalLine,
1438    Slash,
1439    Hyphen,
1440    Space,
1441    None,
1442    /// Custom delimiter string (e.g., ": ").
1443    #[serde(untagged)]
1444    Custom(String),
1445}
1446
1447#[cfg(feature = "schema")]
1448impl JsonSchema for DelimiterPunctuation {
1449    fn schema_name() -> std::borrow::Cow<'static, str> {
1450        "DelimiterPunctuation".into()
1451    }
1452
1453    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1454        schemars::json_schema!({"type": "string", "description": "Delimiter punctuation options."})
1455    }
1456}
1457
1458impl DelimiterPunctuation {
1459    /// Convert this delimiter to a string with trailing space.
1460    ///
1461    /// Returns the punctuation followed by a space, except for Space (single space) and None (empty string).
1462    pub fn to_string_with_space(&self) -> String {
1463        match self {
1464            Self::Comma => ", ".to_string(),
1465            Self::Semicolon => "; ".to_string(),
1466            Self::Period => ". ".to_string(),
1467            Self::Colon => ": ".to_string(),
1468            Self::Ampersand => " & ".to_string(),
1469            Self::VerticalLine => " | ".to_string(),
1470            Self::Slash => "/".to_string(),
1471            Self::Hyphen => "-".to_string(),
1472            Self::Space => " ".to_string(),
1473            Self::None => "".to_string(),
1474            Self::Custom(s) => s.clone(),
1475        }
1476    }
1477
1478    /// Parse a delimiter from a CSL 1.0 delimiter string.
1479    ///
1480    /// Handles common patterns like ", ", ": ", etc.
1481    /// Returns the Custom variant for unrecognized delimiters.
1482    pub fn from_csl_string(s: &str) -> Self {
1483        if s == " " {
1484            return Self::Space;
1485        }
1486
1487        let trimmed = s.trim();
1488        if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
1489            return Self::None;
1490        }
1491
1492        match trimmed {
1493            "," => Self::Comma,
1494            ";" => Self::Semicolon,
1495            "." => Self::Period,
1496            ":" => Self::Colon,
1497            "&" => Self::Ampersand,
1498            "|" => Self::VerticalLine,
1499            "/" => Self::Slash,
1500            "-" => Self::Hyphen,
1501            _ => Self::Custom(s.to_string()),
1502        }
1503    }
1504}
1505
1506#[cfg(test)]
1507#[allow(
1508    clippy::unwrap_used,
1509    clippy::expect_used,
1510    clippy::panic,
1511    clippy::indexing_slicing,
1512    clippy::todo,
1513    clippy::unimplemented,
1514    clippy::unreachable,
1515    clippy::get_unwrap,
1516    reason = "Panicking is acceptable and often desired in tests."
1517)]
1518mod tests {
1519    use super::*;
1520
1521    #[test]
1522    fn test_contributor_deserialization() {
1523        let yaml = r#"
1524contributor: author
1525form: long
1526"#;
1527        let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1528        assert_eq!(comp.contributor, ContributorRole::Author);
1529        assert_eq!(comp.form, ContributorForm::Long);
1530    }
1531
1532    #[test]
1533    fn test_template_component_untagged() {
1534        let yaml = r#"
1535- contributor: author
1536  form: short
1537- date: issued
1538  form: year
1539- title: primary
1540"#;
1541        let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1542        assert_eq!(components.len(), 3);
1543
1544        match &components[0] {
1545            TemplateComponent::Contributor(c) => {
1546                assert_eq!(c.contributor, ContributorRole::Author);
1547            }
1548            _ => panic!("Expected Contributor"),
1549        }
1550
1551        match &components[1] {
1552            TemplateComponent::Date(d) => {
1553                assert_eq!(d.date, DateVariable::Issued);
1554            }
1555            _ => panic!("Expected Date"),
1556        }
1557    }
1558
1559    #[test]
1560    fn test_flattened_rendering() {
1561        // Test that rendering options can be specified directly on the component
1562        let yaml = r#"
1563- title: parent-monograph
1564  prefix: "In "
1565  emph: true
1566- date: issued
1567  form: year
1568  wrap: parentheses
1569"#;
1570        let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1571        assert_eq!(components.len(), 2);
1572
1573        match &components[0] {
1574            TemplateComponent::Title(t) => {
1575                assert_eq!(t.rendering.prefix, Some("In ".to_string()));
1576                assert_eq!(t.rendering.emph, Some(true));
1577            }
1578            _ => panic!("Expected Title"),
1579        }
1580
1581        match &components[1] {
1582            TemplateComponent::Date(d) => {
1583                assert_eq!(
1584                    d.rendering.wrap,
1585                    Some(WrapConfig {
1586                        punctuation: WrapPunctuation::Parentheses,
1587                        inner_prefix: None,
1588                        inner_suffix: None,
1589                    })
1590                );
1591            }
1592            _ => panic!("Expected Date"),
1593        }
1594    }
1595
1596    #[test]
1597    fn test_number_variable_custom_normalizes_manual_construction() {
1598        let number = NumberVariable::Custom("Reel Label".to_string());
1599
1600        assert_eq!(number.as_key(), "reel-label");
1601        assert_eq!(
1602            number,
1603            serde_yaml::from_str::<NumberVariable>("reel-label")
1604                .expect("custom number variable should parse")
1605        );
1606        assert_eq!(
1607            serde_json::to_string(&number).expect("custom number variable should serialize"),
1608            "\"reel-label\""
1609        );
1610    }
1611
1612    #[test]
1613    fn test_contributor_with_wrap() {
1614        let yaml = r#"
1615contributor: publisher
1616form: short
1617wrap: parentheses
1618"#;
1619        let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1620        assert_eq!(comp.contributor, ContributorRole::Publisher);
1621        assert_eq!(
1622            comp.rendering.wrap,
1623            Some(WrapConfig {
1624                punctuation: WrapPunctuation::Parentheses,
1625                inner_prefix: None,
1626                inner_suffix: None,
1627            })
1628        );
1629    }
1630
1631    #[test]
1632    fn test_variable_deserialization() {
1633        // Test that `variable: publisher` parses as Variable, not Number
1634        let yaml = "variable: publisher\n";
1635        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1636        match comp {
1637            TemplateComponent::Variable(v) => {
1638                assert_eq!(v.variable, SimpleVariable::Publisher);
1639            }
1640            _ => panic!("Expected Variable(Publisher), got {:?}", comp),
1641        }
1642    }
1643
1644    #[test]
1645    fn test_message_component_deserialization() {
1646        let yaml = r#"
1647message: pattern.in-container
1648args:
1649  container:
1650    group:
1651    - title: parent-monograph
1652      emph: true
1653text-case: capitalize-first
1654"#;
1655        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1656
1657        match comp {
1658            TemplateComponent::Message(message) => {
1659                assert_eq!(message.message, "pattern.in-container");
1660                assert!(matches!(
1661                    message.args.get("container"),
1662                    Some(MessageArgSource::Group(group)) if group.group.len() == 1
1663                        && matches!(
1664                            group.group.first(),
1665                            Some(TemplateComponent::Title(title))
1666                                if title.title == TitleType::ParentMonograph
1667                                    && title.rendering.emph == Some(true)
1668                        )
1669                ));
1670                assert_eq!(
1671                    message.rendering.text_case,
1672                    Some(crate::options::titles::TextCase::CapitalizeFirst)
1673                );
1674            }
1675            _ => panic!("Expected Message component, got {comp:?}"),
1676        }
1677    }
1678
1679    #[test]
1680    fn test_term_backed_message_component_deserializes_form() {
1681        let yaml = r#"
1682message: term.in
1683form: long
1684suffix: ":"
1685"#;
1686        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1687
1688        match comp {
1689            TemplateComponent::Message(message) => {
1690                assert_eq!(message.message, "term.in");
1691                assert_eq!(message.form, Some(TermForm::Long));
1692                assert_eq!(message.rendering.suffix.as_deref(), Some(":"));
1693            }
1694            _ => panic!("Expected Message component, got {comp:?}"),
1695        }
1696    }
1697
1698    #[test]
1699    fn test_group_deserializes_term_backed_message_component_with_form() {
1700        let yaml = r#"
1701group:
1702- message: term.in
1703  form: long
1704  suffix: ":"
1705- title: parent-monograph
1706"#;
1707        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1708
1709        match comp {
1710            TemplateComponent::Group(group) => {
1711                assert!(matches!(
1712                    group.group.first(),
1713                    Some(TemplateComponent::Message(message))
1714                        if message.message == "term.in"
1715                            && message.form == Some(TermForm::Long)
1716                            && message.rendering.suffix.as_deref() == Some(":")
1717                ));
1718            }
1719            _ => panic!("Expected Group component, got {comp:?}"),
1720        }
1721    }
1722
1723    #[test]
1724    fn test_variable_array_parsing() {
1725        let yaml = r#"
1726- variable: doi
1727  prefix: "https://doi.org/"
1728- variable: publisher
1729"#;
1730        let comps: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1731        assert_eq!(comps.len(), 2);
1732        match &comps[0] {
1733            TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Doi),
1734            _ => panic!("Expected Variable for doi, got {:?}", comps[0]),
1735        }
1736        match &comps[1] {
1737            TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Publisher),
1738            _ => panic!("Expected Variable for publisher, got {:?}", comps[1]),
1739        }
1740    }
1741
1742    #[test]
1743    fn test_type_selector_default_only_matches_default_context() {
1744        let selector = TypeSelector::Single("default".to_string());
1745        assert!(selector.matches("default"));
1746        assert!(!selector.matches("article-journal"));
1747
1748        let mixed = TypeSelector::Multiple(vec!["default".to_string(), "chapter".to_string()]);
1749        assert!(mixed.matches("default"));
1750        assert!(mixed.matches("chapter"));
1751        assert!(!mixed.matches("book"));
1752    }
1753
1754    #[test]
1755    fn test_template_component_selector_matches_nested_partial_group() {
1756        let component: TemplateComponent = serde_yaml::from_str(
1757            r#"
1758delimiter: ""
1759group:
1760- number: citation-number
1761  wrap:
1762    punctuation: brackets
1763- contributor: author
1764  form: long
1765"#,
1766        )
1767        .unwrap();
1768        let selector = TemplateComponentSelector {
1769            fields: BTreeMap::from([(
1770                "group".to_string(),
1771                serde_json::json!([
1772                    { "number": "citation-number" },
1773                    { "contributor": "author" }
1774                ]),
1775            )]),
1776        };
1777
1778        assert!(selector.matches(&component));
1779    }
1780
1781    #[test]
1782    fn test_delimiter_from_csl_string_normalizes_none_and_trimmed_values() {
1783        assert_eq!(
1784            DelimiterPunctuation::from_csl_string("none"),
1785            DelimiterPunctuation::None
1786        );
1787        assert_eq!(
1788            DelimiterPunctuation::from_csl_string(" none "),
1789            DelimiterPunctuation::None
1790        );
1791        assert_eq!(
1792            DelimiterPunctuation::from_csl_string(" "),
1793            DelimiterPunctuation::Space
1794        );
1795        assert_eq!(
1796            DelimiterPunctuation::from_csl_string(" : "),
1797            DelimiterPunctuation::Colon
1798        );
1799    }
1800}