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}
480
481impl Default for TemplateComponent {
482    fn default() -> Self {
483        TemplateComponent::Variable(TemplateVariable::default())
484    }
485}
486
487impl TemplateComponent {
488    /// Return the rendering options for this component.
489    ///
490    /// Every template component has rendering options like emphasis, wrapping, and prefixes.
491    pub fn rendering(&self) -> &Rendering {
492        crate::dispatch_component!(self, |inner| &inner.rendering)
493    }
494
495    /// Return the mutable rendering options for this component.
496    ///
497    /// Provides mutable access to rendering fields (prefix, suffix, etc.)
498    /// that are present on all template component variants.
499    pub fn rendering_mut(&mut self) -> &mut Rendering {
500        crate::dispatch_component!(self, |inner| &mut inner.rendering)
501    }
502}
503
504/// Type-specific template override, either as a complete legacy template or a V3 diff.
505#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
506#[cfg_attr(feature = "schema", derive(JsonSchema))]
507#[serde(untagged)]
508pub enum TemplateVariant {
509    /// Complete replacement template used by Template V1/V2 styles.
510    Full(Vec<TemplateComponent>),
511    /// Structural diff applied to a parent template during style resolution.
512    Diff(TemplateVariantDiff),
513}
514
515impl TemplateVariant {
516    /// Return this variant as a concrete template if it has already been resolved.
517    #[must_use]
518    pub fn as_template(&self) -> Option<&[TemplateComponent]> {
519        match self {
520            Self::Full(template) => Some(template.as_slice()),
521            Self::Diff(_) => None,
522        }
523    }
524
525    /// Return this variant as a mutable concrete template if it has already been resolved.
526    pub fn as_template_mut(&mut self) -> Option<&mut Vec<TemplateComponent>> {
527        match self {
528            Self::Full(template) => Some(template),
529            Self::Diff(_) => None,
530        }
531    }
532
533    /// Convert this variant into its concrete template if it has already been resolved.
534    #[must_use]
535    pub fn into_template(self) -> Option<Vec<TemplateComponent>> {
536        match self {
537            Self::Full(template) => Some(template),
538            Self::Diff(_) => None,
539        }
540    }
541}
542
543impl From<Vec<TemplateComponent>> for TemplateVariant {
544    fn from(template: Vec<TemplateComponent>) -> Self {
545        Self::Full(template)
546    }
547}
548
549/// Structural diff that derives a type-specific template from a parent template.
550#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
551#[cfg_attr(feature = "schema", derive(JsonSchema))]
552#[serde(rename_all = "kebab-case", deny_unknown_fields)]
553pub struct TemplateVariantDiff {
554    /// Optional parent type variant selector within the same section.
555    #[serde(skip_serializing_if = "Option::is_none")]
556    pub extends: Option<TypeSelector>,
557    /// Rendering-only modifications applied in authored order.
558    #[serde(skip_serializing_if = "Vec::is_empty", default)]
559    pub modify: Vec<TemplateModifyOperation>,
560    /// Component removals applied in authored order.
561    #[serde(skip_serializing_if = "Vec::is_empty", default)]
562    pub remove: Vec<TemplateRemoveOperation>,
563    /// Component additions applied in authored order.
564    #[serde(skip_serializing_if = "Vec::is_empty", default)]
565    pub add: Vec<TemplateAddOperation>,
566}
567
568/// Partial component selector used to locate anchors in a template.
569#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
570#[cfg_attr(feature = "schema", derive(JsonSchema))]
571#[serde(transparent)]
572pub struct TemplateComponentSelector {
573    /// Component fields that must be present with equal values on the target component.
574    pub fields: BTreeMap<String, serde_json::Value>,
575}
576
577impl TemplateComponentSelector {
578    /// Returns `true` when this selector has no fields.
579    #[must_use]
580    pub fn is_empty(&self) -> bool {
581        self.fields.is_empty()
582    }
583
584    /// Returns `true` when every selector field is present with the same value.
585    #[must_use]
586    pub fn matches(&self, component: &TemplateComponent) -> bool {
587        let Ok(serde_json::Value::Object(component_fields)) = serde_json::to_value(component)
588        else {
589            return false;
590        };
591
592        self.fields.iter().all(|(key, expected)| {
593            component_fields
594                .get(key)
595                .is_some_and(|actual| selector_value_matches(expected, actual))
596        })
597    }
598}
599
600fn selector_value_matches(expected: &serde_json::Value, actual: &serde_json::Value) -> bool {
601    match (expected, actual) {
602        (serde_json::Value::Object(expected_fields), serde_json::Value::Object(actual_fields)) => {
603            expected_fields.iter().all(|(key, expected_value)| {
604                actual_fields.get(key).is_some_and(|actual_value| {
605                    selector_value_matches(expected_value, actual_value)
606                })
607            })
608        }
609        (serde_json::Value::Array(expected_items), serde_json::Value::Array(actual_items)) => {
610            expected_items.len() == actual_items.len()
611                && expected_items.iter().zip(actual_items.iter()).all(
612                    |(expected_item, actual_item)| {
613                        selector_value_matches(expected_item, actual_item)
614                    },
615                )
616        }
617        _ => expected == actual,
618    }
619}
620
621/// Rendering-only modification for the component matched by `match`.
622#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
623#[cfg_attr(feature = "schema", derive(JsonSchema))]
624#[serde(rename_all = "kebab-case", deny_unknown_fields)]
625pub struct TemplateModifyOperation {
626    /// Selector identifying exactly one component to modify.
627    #[serde(rename = "match")]
628    pub match_selector: TemplateComponentSelector,
629    /// Override the localized number label form when modifying number components.
630    #[serde(skip_serializing_if = "Option::is_none")]
631    pub label_form: Option<LabelForm>,
632    /// Rendering fields to merge onto the matched component.
633    #[serde(flatten, default)]
634    pub rendering: Rendering,
635}
636
637/// Removal operation for the component matched by `match`.
638#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
639#[cfg_attr(feature = "schema", derive(JsonSchema))]
640#[serde(rename_all = "kebab-case", deny_unknown_fields)]
641pub struct TemplateRemoveOperation {
642    /// Selector identifying exactly one component to remove.
643    #[serde(rename = "match")]
644    pub match_selector: TemplateComponentSelector,
645}
646
647/// Addition operation that inserts a component before or after an anchor.
648#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
649#[cfg_attr(feature = "schema", derive(JsonSchema))]
650#[serde(rename_all = "kebab-case", deny_unknown_fields)]
651pub struct TemplateAddOperation {
652    /// Anchor selector before which the component should be inserted.
653    #[serde(skip_serializing_if = "Option::is_none")]
654    pub before: Option<TemplateComponentSelector>,
655    /// Anchor selector after which the component should be inserted.
656    #[serde(skip_serializing_if = "Option::is_none")]
657    pub after: Option<TemplateComponentSelector>,
658    /// Component to insert.
659    pub component: TemplateComponent,
660}
661
662/// Configuration for role labels (e.g., "eds.", "trans.").
663#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
664#[cfg_attr(feature = "schema", derive(JsonSchema))]
665#[serde(rename_all = "kebab-case")]
666pub struct RoleLabel {
667    /// Locale term key for the role (e.g., "editor", "translator").
668    pub term: String,
669    /// Term form: short ("eds.") or long ("editors").
670    #[serde(default)]
671    pub form: RoleLabelForm,
672    /// Where to place the label relative to names.
673    #[serde(default)]
674    pub placement: LabelPlacement,
675    /// Optional case transform applied to the resolved label term, e.g.
676    /// `capitalize-first` renders "Eds." from the locale's "eds." (as IEEE
677    /// requires). When unset the term is rendered as the locale stores it.
678    #[serde(default, skip_serializing_if = "Option::is_none")]
679    pub text_case: Option<crate::options::titles::TextCase>,
680}
681
682/// Term form for role labels.
683#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
684#[cfg_attr(feature = "schema", derive(JsonSchema))]
685#[serde(rename_all = "kebab-case")]
686pub enum RoleLabelForm {
687    #[default]
688    Short,
689    Long,
690}
691
692/// Label placement relative to contributor names.
693#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
694#[cfg_attr(feature = "schema", derive(JsonSchema))]
695#[serde(rename_all = "kebab-case")]
696pub enum LabelPlacement {
697    Prefix,
698    #[default]
699    Suffix,
700}
701
702/// A contributor component for rendering names.
703#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
704#[cfg_attr(feature = "schema", derive(JsonSchema))]
705#[serde(rename_all = "kebab-case", deny_unknown_fields)]
706pub struct TemplateContributor {
707    /// Which contributor role to render (author, editor, etc.).
708    pub contributor: ContributorRole,
709    /// How to display the contributor (long names, short, with label, etc.).
710    pub form: ContributorForm,
711    /// Optional role label configuration (e.g., "eds." for editors).
712    #[serde(skip_serializing_if = "Option::is_none")]
713    pub label: Option<RoleLabel>,
714    /// Override the global name order for this specific component.
715    /// Use to show editors as "Given Family" even when global setting is "Family, Given".
716    #[serde(skip_serializing_if = "Option::is_none")]
717    pub name_order: Option<NameOrder>,
718    /// Override the name form (e.g., initials, full, family-only) for this specific component.
719    #[serde(skip_serializing_if = "Option::is_none", rename = "name-form")]
720    pub name_form: Option<crate::options::contributors::NameForm>,
721    /// Custom delimiter between names (overrides global setting).
722    #[serde(skip_serializing_if = "Option::is_none")]
723    pub delimiter: Option<String>,
724    /// Delimiter between family and given name when inverted (overrides global setting).
725    #[serde(skip_serializing_if = "Option::is_none")]
726    pub sort_separator: Option<String>,
727    /// Shorten the list of names (et al. configuration).
728    #[serde(skip_serializing_if = "Option::is_none")]
729    pub shorten: Option<crate::options::ShortenListOptions>,
730    /// Override the conjunction between the last two names.
731    /// Use `none` for bibliography when citation uses `text` or `symbol`.
732    #[serde(skip_serializing_if = "Option::is_none")]
733    pub and: Option<crate::options::AndOptions>,
734    #[serde(flatten, default)]
735    pub rendering: Rendering,
736    /// Structured link options (DOI, URL).
737    #[serde(skip_serializing_if = "Option::is_none")]
738    pub links: Option<crate::options::LinksConfig>,
739    /// Explicit grammatical gender override for role-label agreement.
740    #[serde(skip_serializing_if = "Option::is_none")]
741    pub gender: Option<GrammaticalGender>,
742
743    /// Custom user-defined fields for extensions.
744    #[serde(skip_serializing_if = "Option::is_none")]
745    pub custom: Option<HashMap<String, serde_json::Value>>,
746}
747
748/// Name display order.
749#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
750#[cfg_attr(feature = "schema", derive(JsonSchema))]
751#[serde(rename_all = "kebab-case")]
752pub enum NameOrder {
753    /// Display as "Given Family" (e.g., "John Smith").
754    GivenFirst,
755    /// Display as "Family, Given" (e.g., "Smith, John").
756    #[default]
757    FamilyFirst,
758    /// First contributor inverted ("Family, Given"); subsequent contributors given-first.
759    FamilyFirstOnly,
760}
761
762/// How to render contributor names.
763#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
764#[cfg_attr(feature = "schema", derive(JsonSchema))]
765#[serde(rename_all = "kebab-case")]
766pub enum ContributorForm {
767    #[default]
768    Long,
769    Short,
770    FamilyOnly,
771    Verb,
772    VerbShort,
773}
774
775crate::str_enum! {
776    /// Contributor roles.
777    #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
778    pub enum ContributorRole {
779        #[default] Author = "author",
780        Chair = "chair",
781        Editor = "editor",
782        Translator = "translator",
783        Director = "director",
784        Publisher = "publisher",
785        Recipient = "recipient",
786        Interviewer = "interviewer",
787        Interviewee = "interviewee",
788        Guest = "guest",
789        Inventor = "inventor",
790        Counsel = "counsel",
791        Composer = "composer",
792        CollectionEditor = "collection-editor",
793        ContainerAuthor = "container-author",
794        EditorialDirector = "editorial-director",
795        TextualEditor = "textual-editor",
796        Illustrator = "illustrator",
797        OriginalAuthor = "original-author",
798        ReviewedAuthor = "reviewed-author"
799    }
800}
801
802/// A date component for rendering dates.
803#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
804#[cfg_attr(feature = "schema", derive(JsonSchema))]
805#[serde(rename_all = "kebab-case", deny_unknown_fields)]
806pub struct TemplateDate {
807    pub date: DateVariable,
808    pub form: DateForm,
809    /// Fallback components if the primary date is missing.
810    #[serde(skip_serializing_if = "Option::is_none")]
811    pub fallback: Option<Vec<TemplateComponent>>,
812    #[serde(flatten, default)]
813    pub rendering: Rendering,
814    /// Structured link options (DOI, URL).
815    #[serde(skip_serializing_if = "Option::is_none")]
816    pub links: Option<crate::options::LinksConfig>,
817
818    /// Custom user-defined fields for extensions.
819    #[serde(skip_serializing_if = "Option::is_none")]
820    pub custom: Option<HashMap<String, serde_json::Value>>,
821}
822
823/// Date variables.
824#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
825#[cfg_attr(feature = "schema", derive(JsonSchema))]
826#[serde(rename_all = "kebab-case")]
827pub enum DateVariable {
828    #[default]
829    Issued,
830    Accessed,
831    OriginalPublished,
832    Submitted,
833    EventDate,
834}
835
836crate::str_enum! {
837    /// Date rendering forms.
838    #[derive(Debug, Default, Clone, PartialEq)]
839    pub enum DateForm {
840        #[default]
841        Year = "year",
842        YearMonth = "year-month",
843        /// Month name only, no year or day: "June" (e.g. magazines whose year
844        /// is already supplied by the author-date position).
845        Month = "month",
846        Full = "full",
847        MonthDay = "month-day",
848        YearMonthDay = "year-month-day",
849        DayMonthAbbrYear = "day-month-abbr-year",
850        /// Abbreviated month + day + year in US order: "Jan 15, 2024".
851        MonthAbbrDayYear = "month-abbr-day-year"
852    }
853}
854
855/// A title component.
856#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
857#[cfg_attr(feature = "schema", derive(JsonSchema))]
858#[serde(rename_all = "kebab-case", deny_unknown_fields)]
859pub struct TemplateTitle {
860    pub title: TitleType,
861    #[serde(skip_serializing_if = "Option::is_none")]
862    pub form: Option<TitleForm>,
863    /// When true, suppress this title component unless the reference needs
864    /// disambiguation (i.e. multiple works by the same author appear in the
865    /// document). Used by author-class styles (e.g. MLA) where the title
866    /// appears in citations only to resolve same-author ambiguity.
867    #[serde(skip_serializing_if = "Option::is_none")]
868    pub disambiguate_only: Option<bool>,
869    #[serde(flatten, default)]
870    pub rendering: Rendering,
871    /// Structured link options (DOI, URL).
872    #[serde(skip_serializing_if = "Option::is_none")]
873    pub links: Option<crate::options::LinksConfig>,
874
875    /// Custom user-defined fields for extensions.
876    #[serde(skip_serializing_if = "Option::is_none")]
877    pub custom: Option<HashMap<String, serde_json::Value>>,
878}
879
880/// Types of titles.
881#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
882#[cfg_attr(feature = "schema", derive(JsonSchema))]
883#[serde(rename_all = "kebab-case")]
884#[non_exhaustive]
885pub enum TitleType {
886    /// The primary title of the cited work.
887    #[default]
888    Primary,
889    /// Title of the parent work containing the cited work.
890    ContainerTitle,
891    /// Title of a book/monograph containing the cited work.
892    ParentMonograph,
893    /// Title of a periodical/serial containing the cited work.
894    ParentSerial,
895    /// Title of a series or collection containing the cited work.
896    CollectionTitle,
897}
898
899/// Title rendering forms.
900#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
901#[cfg_attr(feature = "schema", derive(JsonSchema))]
902#[serde(rename_all = "kebab-case")]
903pub enum TitleForm {
904    Short,
905    #[default]
906    Long,
907}
908
909/// A number component (volume, issue, pages, etc.).
910#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
911#[cfg_attr(feature = "schema", derive(JsonSchema))]
912#[serde(rename_all = "kebab-case", deny_unknown_fields)]
913pub struct TemplateNumber {
914    pub number: NumberVariable,
915    #[serde(skip_serializing_if = "Option::is_none")]
916    pub form: Option<NumberForm>,
917    #[serde(skip_serializing_if = "Option::is_none")]
918    pub label_form: Option<LabelForm>,
919    /// When `true`, show this pages component even when a locator is present in a note-style citation.
920    /// By default, pages are suppressed in note-style citations when a locator is present.
921    #[serde(skip_serializing_if = "Option::is_none")]
922    pub show_with_locator: Option<bool>,
923    #[serde(flatten)]
924    pub rendering: Rendering,
925    /// Structured link options (DOI, URL).
926    #[serde(skip_serializing_if = "Option::is_none")]
927    pub links: Option<crate::options::LinksConfig>,
928    /// Explicit grammatical gender override for number/ordinal agreement.
929    #[serde(skip_serializing_if = "Option::is_none")]
930    pub gender: Option<GrammaticalGender>,
931
932    /// Custom user-defined fields for extensions.
933    #[serde(skip_serializing_if = "Option::is_none")]
934    pub custom: Option<HashMap<String, serde_json::Value>>,
935}
936
937/// Number variables.
938///
939/// Use `number:` when the value is treated as a number by the style:
940/// numeric labels, numeric-specific formatting, ordinals, roman numerals, or
941/// locator-aware punctuation. Use `variable:` instead when the field should be
942/// passed through as plain text without number formatting semantics.
943#[derive(Debug, Default, Clone)]
944#[non_exhaustive]
945pub enum NumberVariable {
946    #[default]
947    Volume,
948    Issue,
949    Pages,
950    Edition,
951    ChapterNumber,
952    CollectionNumber,
953    NumberOfPages,
954    NumberOfVolumes,
955    CitationNumber,
956    /// First-occurrence note number for the cited reference (note styles only).
957    /// Populated from the document processor; omitted (not rendered) when the
958    /// citation is not in a subsequent position or no first-note number is available.
959    FirstReferenceNoteNumber,
960    CitationLabel,
961    Number,
962    DocketNumber,
963    PatentNumber,
964    StandardNumber,
965    ReportNumber,
966    PartNumber,
967    SupplementNumber,
968    PrintingNumber,
969    /// A custom numbering variable rendered from an arbitrary numbering kind.
970    Custom(String),
971}
972
973impl NumberVariable {
974    /// Return the canonical kebab-case key for this numeric variable.
975    #[must_use]
976    pub fn as_key(&self) -> Cow<'_, str> {
977        match self {
978            Self::Volume => Cow::Borrowed("volume"),
979            Self::Issue => Cow::Borrowed("issue"),
980            Self::Pages => Cow::Borrowed("pages"),
981            Self::Edition => Cow::Borrowed("edition"),
982            Self::ChapterNumber => Cow::Borrowed("chapter-number"),
983            Self::CollectionNumber => Cow::Borrowed("collection-number"),
984            Self::NumberOfPages => Cow::Borrowed("number-of-pages"),
985            Self::NumberOfVolumes => Cow::Borrowed("number-of-volumes"),
986            Self::CitationNumber => Cow::Borrowed("citation-number"),
987            Self::FirstReferenceNoteNumber => Cow::Borrowed("first-reference-note-number"),
988            Self::CitationLabel => Cow::Borrowed("citation-label"),
989            Self::Number => Cow::Borrowed("number"),
990            Self::DocketNumber => Cow::Borrowed("docket-number"),
991            Self::PatentNumber => Cow::Borrowed("patent-number"),
992            Self::StandardNumber => Cow::Borrowed("standard-number"),
993            Self::ReportNumber => Cow::Borrowed("report-number"),
994            Self::PartNumber => Cow::Borrowed("part-number"),
995            Self::SupplementNumber => Cow::Borrowed("supplement-number"),
996            Self::PrintingNumber => Cow::Borrowed("printing-number"),
997            Self::Custom(value) => normalize_kind_key(value)
998                .map(Cow::Owned)
999                .unwrap_or_else(|| Cow::Borrowed(value.as_str())),
1000        }
1001    }
1002
1003    fn from_key(value: &str) -> Result<Self, String> {
1004        let canonical = normalize_kind_key(value)
1005            .ok_or_else(|| "number variable must not be empty".to_string())?;
1006        Ok(match canonical.as_str() {
1007            "volume" => Self::Volume,
1008            "issue" => Self::Issue,
1009            "pages" => Self::Pages,
1010            "edition" => Self::Edition,
1011            "chapter-number" => Self::ChapterNumber,
1012            "collection-number" => Self::CollectionNumber,
1013            "number-of-pages" => Self::NumberOfPages,
1014            "number-of-volumes" => Self::NumberOfVolumes,
1015            "citation-number" => Self::CitationNumber,
1016            "first-reference-note-number" => Self::FirstReferenceNoteNumber,
1017            "citation-label" => Self::CitationLabel,
1018            "number" => Self::Number,
1019            "docket-number" => Self::DocketNumber,
1020            "patent-number" => Self::PatentNumber,
1021            "standard-number" => Self::StandardNumber,
1022            "report-number" => Self::ReportNumber,
1023            "part-number" => Self::PartNumber,
1024            "supplement-number" => Self::SupplementNumber,
1025            "printing-number" => Self::PrintingNumber,
1026            _ => Self::Custom(canonical),
1027        })
1028    }
1029}
1030
1031impl PartialEq for NumberVariable {
1032    fn eq(&self, other: &Self) -> bool {
1033        self.as_key().as_ref() == other.as_key().as_ref()
1034    }
1035}
1036
1037impl Eq for NumberVariable {}
1038
1039impl Hash for NumberVariable {
1040    fn hash<H: Hasher>(&self, state: &mut H) {
1041        self.as_key().as_ref().hash(state);
1042    }
1043}
1044
1045impl Serialize for NumberVariable {
1046    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1047    where
1048        S: Serializer,
1049    {
1050        serializer.serialize_str(self.as_key().as_ref())
1051    }
1052}
1053
1054impl<'de> Deserialize<'de> for NumberVariable {
1055    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1056    where
1057        D: Deserializer<'de>,
1058    {
1059        let value = String::deserialize(deserializer)?;
1060        Self::from_key(&value).map_err(serde::de::Error::custom)
1061    }
1062}
1063
1064#[cfg(feature = "schema")]
1065impl JsonSchema for NumberVariable {
1066    fn schema_name() -> std::borrow::Cow<'static, str> {
1067        "NumberVariable".into()
1068    }
1069
1070    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1071        schemars::json_schema!({
1072            "type": "string",
1073            "description": "Known number variable keyword or custom kebab-case identifier."
1074        })
1075    }
1076}
1077
1078/// Number rendering forms.
1079#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1080#[cfg_attr(feature = "schema", derive(JsonSchema))]
1081#[serde(rename_all = "lowercase")]
1082pub enum NumberForm {
1083    #[default]
1084    Numeric,
1085    Ordinal,
1086    Roman,
1087}
1088
1089fn normalize_kind_key(value: &str) -> Option<String> {
1090    let mut normalized = String::new();
1091    let mut pending_dash = false;
1092
1093    for ch in value.trim().chars() {
1094        if ch.is_ascii_alphanumeric() {
1095            if pending_dash && !normalized.is_empty() {
1096                normalized.push('-');
1097            }
1098            normalized.push(ch.to_ascii_lowercase());
1099            pending_dash = false;
1100        } else if !normalized.is_empty() {
1101            pending_dash = true;
1102        }
1103    }
1104
1105    if normalized.is_empty() {
1106        None
1107    } else {
1108        Some(normalized)
1109    }
1110}
1111
1112/// Label rendering forms.
1113#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1114#[cfg_attr(feature = "schema", derive(JsonSchema))]
1115#[serde(rename_all = "kebab-case")]
1116pub enum LabelForm {
1117    Long,
1118    #[default]
1119    Short,
1120    Symbol,
1121}
1122
1123/// A simple variable component (DOI, ISBN, URL, etc.).
1124#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1125#[cfg_attr(feature = "schema", derive(JsonSchema))]
1126#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1127pub struct TemplateVariable {
1128    pub variable: SimpleVariable,
1129    #[serde(flatten)]
1130    pub rendering: Rendering,
1131    /// Structured link options (DOI, URL).
1132    #[serde(skip_serializing_if = "Option::is_none")]
1133    pub links: Option<crate::options::LinksConfig>,
1134
1135    /// Custom user-defined fields for extensions.
1136    #[serde(skip_serializing_if = "Option::is_none")]
1137    pub custom: Option<HashMap<String, serde_json::Value>>,
1138}
1139
1140/// A locale message call inside a citation or bibliography template.
1141///
1142/// The style chooses the message ID and supplies structured argument sources;
1143/// the active locale owns the natural-language realization in its `messages`
1144/// map.
1145#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1146#[cfg_attr(feature = "schema", derive(JsonSchema))]
1147#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1148pub struct TemplateMessage {
1149    /// Locale message ID to evaluate, such as `pattern.accessed-date`.
1150    pub message: String,
1151    /// Optional term form used when `message` addresses a `term.*` locale item.
1152    #[serde(skip_serializing_if = "Option::is_none")]
1153    pub form: Option<TermForm>,
1154    /// Explicit grammatical gender override for term-backed message selection.
1155    #[serde(skip_serializing_if = "Option::is_none")]
1156    pub gender: Option<GrammaticalGender>,
1157    /// Named argument sources pre-rendered before message evaluation.
1158    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1159    pub args: HashMap<String, MessageArgSource>,
1160    #[serde(flatten, default)]
1161    pub rendering: Rendering,
1162
1163    /// Custom user-defined fields for extensions.
1164    #[serde(skip_serializing_if = "Option::is_none")]
1165    pub custom: Option<HashMap<String, serde_json::Value>>,
1166}
1167
1168/// A structured source for one named locale-message argument.
1169#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1170#[cfg_attr(feature = "schema", derive(JsonSchema))]
1171#[serde(untagged)]
1172pub enum MessageArgSource {
1173    /// A literal string argument.
1174    Literal { literal: String },
1175    /// A rendered contributor argument.
1176    Contributor(TemplateContributor),
1177    /// A rendered date argument.
1178    Date(TemplateDate),
1179    /// A rendered group argument.
1180    Group(TemplateGroup),
1181    /// A rendered title argument.
1182    Title(TemplateTitle),
1183    /// A rendered number argument.
1184    Number(TemplateNumber),
1185    /// A rendered variable argument.
1186    Variable(TemplateVariable),
1187    /// A rendered locale term argument.
1188    Term(TemplateTerm),
1189}
1190
1191impl MessageArgSource {
1192    /// Convert this argument source into a normal template component when it
1193    /// should be rendered through the standard component pipeline.
1194    #[must_use]
1195    pub fn as_template_component(&self) -> Option<TemplateComponent> {
1196        match self {
1197            Self::Literal { .. } => None,
1198            Self::Contributor(component) => Some(TemplateComponent::Contributor(component.clone())),
1199            Self::Date(component) => Some(TemplateComponent::Date(component.clone())),
1200            Self::Group(component) => Some(TemplateComponent::Group(component.clone())),
1201            Self::Title(component) => Some(TemplateComponent::Title(component.clone())),
1202            Self::Number(component) => Some(TemplateComponent::Number(component.clone())),
1203            Self::Variable(component) => Some(TemplateComponent::Variable(component.clone())),
1204            Self::Term(component) => Some(TemplateComponent::Term(component.clone())),
1205        }
1206    }
1207}
1208
1209/// Simple string variables.
1210///
1211/// Use `variable:` for string passthrough fields, even when the field name is
1212/// also present in [`NumberVariable`]. For example, `variable: volume` keeps the
1213/// source value as plain text, while `number: volume` opts into numeric
1214/// formatting behavior.
1215#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1216#[cfg_attr(feature = "schema", derive(JsonSchema))]
1217#[serde(rename_all = "kebab-case")]
1218#[non_exhaustive]
1219pub enum SimpleVariable {
1220    #[default]
1221    Doi,
1222    Isbn,
1223    Issn,
1224    Url,
1225    Pmid,
1226    Pmcid,
1227    Abstract,
1228    Note,
1229    Annote,
1230    Keyword,
1231    Genre,
1232    Medium,
1233    Source,
1234    Status,
1235    Archive,
1236    ArchiveLocation,
1237    ArchiveName,
1238    ArchivePlace,
1239    ArchiveCollection,
1240    ArchiveCollectionId,
1241    ArchiveSeries,
1242    ArchiveBox,
1243    ArchiveFolder,
1244    ArchiveItem,
1245    ArchiveUrl,
1246    EprintId,
1247    EprintServer,
1248    EprintClass,
1249    Publisher,
1250    PublisherPlace,
1251    OriginalPublisher,
1252    OriginalPublisherPlace,
1253    EventPlace,
1254    Dimensions,
1255    Scale,
1256    Version,
1257    Locator,
1258    ContainerTitleShort,
1259    Authority,
1260    Code,
1261    Reporter,
1262    Page,
1263    Section,
1264    Volume,
1265    Number,
1266    DocketNumber,
1267    PatentNumber,
1268    StandardNumber,
1269    ReportNumber,
1270    AdsBibcode,
1271}
1272
1273/// A term component for rendering locale-specific text.
1274#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1275#[cfg_attr(feature = "schema", derive(JsonSchema))]
1276#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1277pub struct TemplateTerm {
1278    /// Which term to render.
1279    pub term: GeneralTerm,
1280    /// Form: long (default), short, or symbol.
1281    #[serde(skip_serializing_if = "Option::is_none")]
1282    pub form: Option<TermForm>,
1283    /// Explicit grammatical gender override for term selection.
1284    #[serde(skip_serializing_if = "Option::is_none")]
1285    pub gender: Option<GrammaticalGender>,
1286    #[serde(flatten, default)]
1287    pub rendering: Rendering,
1288
1289    /// Custom user-defined fields for extensions.
1290    #[serde(skip_serializing_if = "Option::is_none")]
1291    pub custom: Option<HashMap<String, serde_json::Value>>,
1292}
1293
1294/// A group component for grouping multiple components with a delimiter,
1295/// matching CSL 1.0 `<group>` semantics.
1296#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1297#[cfg_attr(feature = "schema", derive(JsonSchema))]
1298#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1299pub struct TemplateGroup {
1300    pub group: Vec<TemplateComponent>,
1301    #[serde(skip_serializing_if = "Option::is_none")]
1302    pub delimiter: Option<DelimiterPunctuation>,
1303    #[serde(flatten, default)]
1304    pub rendering: Rendering,
1305
1306    /// Custom user-defined fields for extensions.
1307    #[serde(skip_serializing_if = "Option::is_none")]
1308    pub custom: Option<HashMap<String, serde_json::Value>>,
1309}
1310
1311/// Delimiter punctuation options.
1312#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)]
1313#[serde(rename_all = "kebab-case")]
1314pub enum DelimiterPunctuation {
1315    #[default]
1316    Comma,
1317    Semicolon,
1318    Period,
1319    Colon,
1320    Ampersand,
1321    VerticalLine,
1322    Slash,
1323    Hyphen,
1324    Space,
1325    None,
1326    /// Custom delimiter string (e.g., ": ").
1327    #[serde(untagged)]
1328    Custom(String),
1329}
1330
1331#[cfg(feature = "schema")]
1332impl JsonSchema for DelimiterPunctuation {
1333    fn schema_name() -> std::borrow::Cow<'static, str> {
1334        "DelimiterPunctuation".into()
1335    }
1336
1337    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1338        schemars::json_schema!({"type": "string", "description": "Delimiter punctuation options."})
1339    }
1340}
1341
1342impl DelimiterPunctuation {
1343    /// Convert this delimiter to a string with trailing space.
1344    ///
1345    /// Returns the punctuation followed by a space, except for Space (single space) and None (empty string).
1346    pub fn to_string_with_space(&self) -> String {
1347        match self {
1348            Self::Comma => ", ".to_string(),
1349            Self::Semicolon => "; ".to_string(),
1350            Self::Period => ". ".to_string(),
1351            Self::Colon => ": ".to_string(),
1352            Self::Ampersand => " & ".to_string(),
1353            Self::VerticalLine => " | ".to_string(),
1354            Self::Slash => "/".to_string(),
1355            Self::Hyphen => "-".to_string(),
1356            Self::Space => " ".to_string(),
1357            Self::None => "".to_string(),
1358            Self::Custom(s) => s.clone(),
1359        }
1360    }
1361
1362    /// Parse a delimiter from a CSL 1.0 delimiter string.
1363    ///
1364    /// Handles common patterns like ", ", ": ", etc.
1365    /// Returns the Custom variant for unrecognized delimiters.
1366    pub fn from_csl_string(s: &str) -> Self {
1367        if s == " " {
1368            return Self::Space;
1369        }
1370
1371        let trimmed = s.trim();
1372        if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
1373            return Self::None;
1374        }
1375
1376        match trimmed {
1377            "," => Self::Comma,
1378            ";" => Self::Semicolon,
1379            "." => Self::Period,
1380            ":" => Self::Colon,
1381            "&" => Self::Ampersand,
1382            "|" => Self::VerticalLine,
1383            "/" => Self::Slash,
1384            "-" => Self::Hyphen,
1385            _ => Self::Custom(s.to_string()),
1386        }
1387    }
1388}
1389
1390#[cfg(test)]
1391#[allow(
1392    clippy::unwrap_used,
1393    clippy::expect_used,
1394    clippy::panic,
1395    clippy::indexing_slicing,
1396    clippy::todo,
1397    clippy::unimplemented,
1398    clippy::unreachable,
1399    clippy::get_unwrap,
1400    reason = "Panicking is acceptable and often desired in tests."
1401)]
1402mod tests {
1403    use super::*;
1404
1405    #[test]
1406    fn test_contributor_deserialization() {
1407        let yaml = r#"
1408contributor: author
1409form: long
1410"#;
1411        let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1412        assert_eq!(comp.contributor, ContributorRole::Author);
1413        assert_eq!(comp.form, ContributorForm::Long);
1414    }
1415
1416    #[test]
1417    fn test_template_component_untagged() {
1418        let yaml = r#"
1419- contributor: author
1420  form: short
1421- date: issued
1422  form: year
1423- title: primary
1424"#;
1425        let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1426        assert_eq!(components.len(), 3);
1427
1428        match &components[0] {
1429            TemplateComponent::Contributor(c) => {
1430                assert_eq!(c.contributor, ContributorRole::Author);
1431            }
1432            _ => panic!("Expected Contributor"),
1433        }
1434
1435        match &components[1] {
1436            TemplateComponent::Date(d) => {
1437                assert_eq!(d.date, DateVariable::Issued);
1438            }
1439            _ => panic!("Expected Date"),
1440        }
1441    }
1442
1443    #[test]
1444    fn test_flattened_rendering() {
1445        // Test that rendering options can be specified directly on the component
1446        let yaml = r#"
1447- title: parent-monograph
1448  prefix: "In "
1449  emph: true
1450- date: issued
1451  form: year
1452  wrap: parentheses
1453"#;
1454        let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1455        assert_eq!(components.len(), 2);
1456
1457        match &components[0] {
1458            TemplateComponent::Title(t) => {
1459                assert_eq!(t.rendering.prefix, Some("In ".to_string()));
1460                assert_eq!(t.rendering.emph, Some(true));
1461            }
1462            _ => panic!("Expected Title"),
1463        }
1464
1465        match &components[1] {
1466            TemplateComponent::Date(d) => {
1467                assert_eq!(
1468                    d.rendering.wrap,
1469                    Some(WrapConfig {
1470                        punctuation: WrapPunctuation::Parentheses,
1471                        inner_prefix: None,
1472                        inner_suffix: None,
1473                    })
1474                );
1475            }
1476            _ => panic!("Expected Date"),
1477        }
1478    }
1479
1480    #[test]
1481    fn test_number_variable_custom_normalizes_manual_construction() {
1482        let number = NumberVariable::Custom("Reel Label".to_string());
1483
1484        assert_eq!(number.as_key(), "reel-label");
1485        assert_eq!(
1486            number,
1487            serde_yaml::from_str::<NumberVariable>("reel-label")
1488                .expect("custom number variable should parse")
1489        );
1490        assert_eq!(
1491            serde_json::to_string(&number).expect("custom number variable should serialize"),
1492            "\"reel-label\""
1493        );
1494    }
1495
1496    #[test]
1497    fn test_contributor_with_wrap() {
1498        let yaml = r#"
1499contributor: publisher
1500form: short
1501wrap: parentheses
1502"#;
1503        let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1504        assert_eq!(comp.contributor, ContributorRole::Publisher);
1505        assert_eq!(
1506            comp.rendering.wrap,
1507            Some(WrapConfig {
1508                punctuation: WrapPunctuation::Parentheses,
1509                inner_prefix: None,
1510                inner_suffix: None,
1511            })
1512        );
1513    }
1514
1515    #[test]
1516    fn test_variable_deserialization() {
1517        // Test that `variable: publisher` parses as Variable, not Number
1518        let yaml = "variable: publisher\n";
1519        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1520        match comp {
1521            TemplateComponent::Variable(v) => {
1522                assert_eq!(v.variable, SimpleVariable::Publisher);
1523            }
1524            _ => panic!("Expected Variable(Publisher), got {:?}", comp),
1525        }
1526    }
1527
1528    #[test]
1529    fn test_message_component_deserialization() {
1530        let yaml = r#"
1531message: pattern.in-container
1532args:
1533  container:
1534    group:
1535    - title: parent-monograph
1536      emph: true
1537text-case: capitalize-first
1538"#;
1539        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1540
1541        match comp {
1542            TemplateComponent::Message(message) => {
1543                assert_eq!(message.message, "pattern.in-container");
1544                assert!(matches!(
1545                    message.args.get("container"),
1546                    Some(MessageArgSource::Group(group)) if group.group.len() == 1
1547                        && matches!(
1548                            group.group.first(),
1549                            Some(TemplateComponent::Title(title))
1550                                if title.title == TitleType::ParentMonograph
1551                                    && title.rendering.emph == Some(true)
1552                        )
1553                ));
1554                assert_eq!(
1555                    message.rendering.text_case,
1556                    Some(crate::options::titles::TextCase::CapitalizeFirst)
1557                );
1558            }
1559            _ => panic!("Expected Message component, got {comp:?}"),
1560        }
1561    }
1562
1563    #[test]
1564    fn test_term_backed_message_component_deserializes_form() {
1565        let yaml = r#"
1566message: term.in
1567form: long
1568suffix: ":"
1569"#;
1570        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1571
1572        match comp {
1573            TemplateComponent::Message(message) => {
1574                assert_eq!(message.message, "term.in");
1575                assert_eq!(message.form, Some(TermForm::Long));
1576                assert_eq!(message.rendering.suffix.as_deref(), Some(":"));
1577            }
1578            _ => panic!("Expected Message component, got {comp:?}"),
1579        }
1580    }
1581
1582    #[test]
1583    fn test_group_deserializes_term_backed_message_component_with_form() {
1584        let yaml = r#"
1585group:
1586- message: term.in
1587  form: long
1588  suffix: ":"
1589- title: parent-monograph
1590"#;
1591        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1592
1593        match comp {
1594            TemplateComponent::Group(group) => {
1595                assert!(matches!(
1596                    group.group.first(),
1597                    Some(TemplateComponent::Message(message))
1598                        if message.message == "term.in"
1599                            && message.form == Some(TermForm::Long)
1600                            && message.rendering.suffix.as_deref() == Some(":")
1601                ));
1602            }
1603            _ => panic!("Expected Group component, got {comp:?}"),
1604        }
1605    }
1606
1607    #[test]
1608    fn test_variable_array_parsing() {
1609        let yaml = r#"
1610- variable: doi
1611  prefix: "https://doi.org/"
1612- variable: publisher
1613"#;
1614        let comps: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1615        assert_eq!(comps.len(), 2);
1616        match &comps[0] {
1617            TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Doi),
1618            _ => panic!("Expected Variable for doi, got {:?}", comps[0]),
1619        }
1620        match &comps[1] {
1621            TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Publisher),
1622            _ => panic!("Expected Variable for publisher, got {:?}", comps[1]),
1623        }
1624    }
1625
1626    #[test]
1627    fn test_type_selector_default_only_matches_default_context() {
1628        let selector = TypeSelector::Single("default".to_string());
1629        assert!(selector.matches("default"));
1630        assert!(!selector.matches("article-journal"));
1631
1632        let mixed = TypeSelector::Multiple(vec!["default".to_string(), "chapter".to_string()]);
1633        assert!(mixed.matches("default"));
1634        assert!(mixed.matches("chapter"));
1635        assert!(!mixed.matches("book"));
1636    }
1637
1638    #[test]
1639    fn test_template_component_selector_matches_nested_partial_group() {
1640        let component: TemplateComponent = serde_yaml::from_str(
1641            r#"
1642delimiter: ""
1643group:
1644- number: citation-number
1645  wrap:
1646    punctuation: brackets
1647- contributor: author
1648  form: long
1649"#,
1650        )
1651        .unwrap();
1652        let selector = TemplateComponentSelector {
1653            fields: BTreeMap::from([(
1654                "group".to_string(),
1655                serde_json::json!([
1656                    { "number": "citation-number" },
1657                    { "contributor": "author" }
1658                ]),
1659            )]),
1660        };
1661
1662        assert!(selector.matches(&component));
1663    }
1664
1665    #[test]
1666    fn test_delimiter_from_csl_string_normalizes_none_and_trimmed_values() {
1667        assert_eq!(
1668            DelimiterPunctuation::from_csl_string("none"),
1669            DelimiterPunctuation::None
1670        );
1671        assert_eq!(
1672            DelimiterPunctuation::from_csl_string(" none "),
1673            DelimiterPunctuation::None
1674        );
1675        assert_eq!(
1676            DelimiterPunctuation::from_csl_string(" "),
1677            DelimiterPunctuation::Space
1678        );
1679        assert_eq!(
1680            DelimiterPunctuation::from_csl_string(" : "),
1681            DelimiterPunctuation::Colon
1682        );
1683    }
1684}