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    /// Every contributor except the last inverted ("Family, Given"); the last
773    /// contributor rendered given-first. "Last" is the last name of the full
774    /// contributor list; under et-al truncation that name may be elided, in
775    /// which case all rendered names invert.
776    FamilyFirstExceptLast,
777}
778
779/// How to render contributor names.
780#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
781#[cfg_attr(feature = "schema", derive(JsonSchema))]
782#[serde(rename_all = "kebab-case")]
783pub enum ContributorForm {
784    #[default]
785    Long,
786    Short,
787    FamilyOnly,
788    Verb,
789    VerbShort,
790}
791
792crate::str_enum! {
793    /// Contributor roles.
794    #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
795    pub enum ContributorRole {
796        #[default] Author = "author",
797        Chair = "chair",
798        Editor = "editor",
799        Translator = "translator",
800        Director = "director",
801        Publisher = "publisher",
802        Recipient = "recipient",
803        Interviewer = "interviewer",
804        Interviewee = "interviewee",
805        Guest = "guest",
806        Performer = "performer",
807        Inventor = "inventor",
808        Counsel = "counsel",
809        Composer = "composer",
810        Writer = "writer",
811        CollectionEditor = "collection-editor",
812        ContainerAuthor = "container-author",
813        EditorialDirector = "editorial-director",
814        TextualEditor = "textual-editor",
815        Illustrator = "illustrator",
816        OriginalAuthor = "original-author",
817        ReviewedAuthor = "reviewed-author"
818    }
819}
820
821/// A date component for rendering dates.
822#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
823#[cfg_attr(feature = "schema", derive(JsonSchema))]
824#[serde(rename_all = "kebab-case", deny_unknown_fields)]
825pub struct TemplateDate {
826    pub date: DateVariable,
827    pub form: DateForm,
828    /// Fallback components if the primary date is missing.
829    #[serde(skip_serializing_if = "Option::is_none")]
830    pub fallback: Option<Vec<TemplateComponent>>,
831    #[serde(flatten, default)]
832    pub rendering: Rendering,
833    /// Structured link options (DOI, URL).
834    #[serde(skip_serializing_if = "Option::is_none")]
835    pub links: Option<crate::options::LinksConfig>,
836
837    /// Custom user-defined fields for extensions.
838    #[serde(skip_serializing_if = "Option::is_none")]
839    pub custom: Option<HashMap<String, serde_json::Value>>,
840}
841
842/// Date variables.
843#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
844#[cfg_attr(feature = "schema", derive(JsonSchema))]
845#[serde(rename_all = "kebab-case")]
846pub enum DateVariable {
847    #[default]
848    Issued,
849    Accessed,
850    OriginalPublished,
851    Submitted,
852    EventDate,
853}
854
855crate::str_enum! {
856    /// Date rendering forms.
857    #[derive(Debug, Default, Clone, PartialEq)]
858    pub enum DateForm {
859        #[default]
860        Year = "year",
861        YearMonth = "year-month",
862        /// Month name only, no year or day: "June" (e.g. magazines whose year
863        /// is already supplied by the author-date position).
864        Month = "month",
865        Full = "full",
866        MonthDay = "month-day",
867        YearMonthDay = "year-month-day",
868        DayMonthAbbrYear = "day-month-abbr-year",
869        /// Abbreviated month + day + year in US order: "Jan 15, 2024".
870        MonthAbbrDayYear = "month-abbr-day-year"
871    }
872}
873
874/// A title component.
875#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
876#[cfg_attr(feature = "schema", derive(JsonSchema))]
877#[serde(rename_all = "kebab-case", deny_unknown_fields)]
878pub struct TemplateTitle {
879    pub title: TitleType,
880    #[serde(skip_serializing_if = "Option::is_none")]
881    pub form: Option<TitleForm>,
882    /// When true, suppress this title component unless the reference needs
883    /// disambiguation (i.e. multiple works by the same author appear in the
884    /// document). Used by author-class styles (e.g. MLA) where the title
885    /// appears in citations only to resolve same-author ambiguity.
886    #[serde(skip_serializing_if = "Option::is_none")]
887    pub disambiguate_only: Option<bool>,
888    #[serde(flatten, default)]
889    pub rendering: Rendering,
890    /// Structured link options (DOI, URL).
891    #[serde(skip_serializing_if = "Option::is_none")]
892    pub links: Option<crate::options::LinksConfig>,
893
894    /// Custom user-defined fields for extensions.
895    #[serde(skip_serializing_if = "Option::is_none")]
896    pub custom: Option<HashMap<String, serde_json::Value>>,
897}
898
899/// Types of titles.
900#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
901#[cfg_attr(feature = "schema", derive(JsonSchema))]
902#[serde(rename_all = "kebab-case")]
903#[non_exhaustive]
904pub enum TitleType {
905    /// The primary title of the cited work.
906    #[default]
907    Primary,
908    /// Title of the parent work containing the cited work.
909    ContainerTitle,
910    /// Title of a book/monograph containing the cited work.
911    ParentMonograph,
912    /// Title of a periodical/serial containing the cited work.
913    ParentSerial,
914    /// Title of a series or collection containing the cited work.
915    CollectionTitle,
916    /// Title of the work's original publication (e.g. a translation's source-language title).
917    Original,
918}
919
920/// Title rendering forms.
921#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
922#[cfg_attr(feature = "schema", derive(JsonSchema))]
923#[serde(rename_all = "kebab-case")]
924pub enum TitleForm {
925    Short,
926    #[default]
927    Long,
928}
929
930/// A number component (volume, issue, pages, etc.).
931#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
932#[cfg_attr(feature = "schema", derive(JsonSchema))]
933#[serde(rename_all = "kebab-case", deny_unknown_fields)]
934pub struct TemplateNumber {
935    pub number: NumberVariable,
936    #[serde(skip_serializing_if = "Option::is_none")]
937    pub form: Option<NumberForm>,
938    #[serde(skip_serializing_if = "Option::is_none")]
939    pub label_form: Option<LabelForm>,
940    /// When `true`, show this pages component even when a locator is present in a note-style citation.
941    /// By default, pages are suppressed in note-style citations when a locator is present.
942    #[serde(skip_serializing_if = "Option::is_none")]
943    pub show_with_locator: Option<bool>,
944    #[serde(flatten)]
945    pub rendering: Rendering,
946    /// Structured link options (DOI, URL).
947    #[serde(skip_serializing_if = "Option::is_none")]
948    pub links: Option<crate::options::LinksConfig>,
949    /// Explicit grammatical gender override for number/ordinal agreement.
950    #[serde(skip_serializing_if = "Option::is_none")]
951    pub gender: Option<GrammaticalGender>,
952
953    /// Custom user-defined fields for extensions.
954    #[serde(skip_serializing_if = "Option::is_none")]
955    pub custom: Option<HashMap<String, serde_json::Value>>,
956}
957
958/// Number variables.
959///
960/// Use `number:` when the value is treated as a number by the style:
961/// numeric labels, numeric-specific formatting, ordinals, roman numerals, or
962/// locator-aware punctuation. Use `variable:` instead when the field should be
963/// passed through as plain text without number formatting semantics.
964#[derive(Debug, Default, Clone)]
965#[non_exhaustive]
966pub enum NumberVariable {
967    #[default]
968    Volume,
969    Issue,
970    Pages,
971    Edition,
972    ChapterNumber,
973    CollectionNumber,
974    NumberOfPages,
975    NumberOfVolumes,
976    CitationNumber,
977    /// First-occurrence note number for the cited reference (note styles only).
978    /// Populated from the document processor; omitted (not rendered) when the
979    /// citation is not in a subsequent position or no first-note number is available.
980    FirstReferenceNoteNumber,
981    CitationLabel,
982    Number,
983    DocketNumber,
984    PatentNumber,
985    StandardNumber,
986    ReportNumber,
987    PartNumber,
988    SupplementNumber,
989    PrintingNumber,
990    /// A custom numbering variable rendered from an arbitrary numbering kind.
991    Custom(String),
992}
993
994impl NumberVariable {
995    /// Return the canonical kebab-case key for this numeric variable.
996    #[must_use]
997    pub fn as_key(&self) -> Cow<'_, str> {
998        match self {
999            Self::Volume => Cow::Borrowed("volume"),
1000            Self::Issue => Cow::Borrowed("issue"),
1001            Self::Pages => Cow::Borrowed("pages"),
1002            Self::Edition => Cow::Borrowed("edition"),
1003            Self::ChapterNumber => Cow::Borrowed("chapter-number"),
1004            Self::CollectionNumber => Cow::Borrowed("collection-number"),
1005            Self::NumberOfPages => Cow::Borrowed("number-of-pages"),
1006            Self::NumberOfVolumes => Cow::Borrowed("number-of-volumes"),
1007            Self::CitationNumber => Cow::Borrowed("citation-number"),
1008            Self::FirstReferenceNoteNumber => Cow::Borrowed("first-reference-note-number"),
1009            Self::CitationLabel => Cow::Borrowed("citation-label"),
1010            Self::Number => Cow::Borrowed("number"),
1011            Self::DocketNumber => Cow::Borrowed("docket-number"),
1012            Self::PatentNumber => Cow::Borrowed("patent-number"),
1013            Self::StandardNumber => Cow::Borrowed("standard-number"),
1014            Self::ReportNumber => Cow::Borrowed("report-number"),
1015            Self::PartNumber => Cow::Borrowed("part-number"),
1016            Self::SupplementNumber => Cow::Borrowed("supplement-number"),
1017            Self::PrintingNumber => Cow::Borrowed("printing-number"),
1018            Self::Custom(value) => normalize_kind_key(value)
1019                .map(Cow::Owned)
1020                .unwrap_or_else(|| Cow::Borrowed(value.as_str())),
1021        }
1022    }
1023
1024    fn from_key(value: &str) -> Result<Self, String> {
1025        let canonical = normalize_kind_key(value)
1026            .ok_or_else(|| "number variable must not be empty".to_string())?;
1027        Ok(match canonical.as_str() {
1028            "volume" => Self::Volume,
1029            "issue" => Self::Issue,
1030            "pages" => Self::Pages,
1031            "edition" => Self::Edition,
1032            "chapter-number" => Self::ChapterNumber,
1033            "collection-number" => Self::CollectionNumber,
1034            "number-of-pages" => Self::NumberOfPages,
1035            "number-of-volumes" => Self::NumberOfVolumes,
1036            "citation-number" => Self::CitationNumber,
1037            "first-reference-note-number" => Self::FirstReferenceNoteNumber,
1038            "citation-label" => Self::CitationLabel,
1039            "number" => Self::Number,
1040            "docket-number" => Self::DocketNumber,
1041            "patent-number" => Self::PatentNumber,
1042            "standard-number" => Self::StandardNumber,
1043            "report-number" => Self::ReportNumber,
1044            "part-number" => Self::PartNumber,
1045            "supplement-number" => Self::SupplementNumber,
1046            "printing-number" => Self::PrintingNumber,
1047            _ => Self::Custom(canonical),
1048        })
1049    }
1050}
1051
1052impl PartialEq for NumberVariable {
1053    fn eq(&self, other: &Self) -> bool {
1054        self.as_key().as_ref() == other.as_key().as_ref()
1055    }
1056}
1057
1058impl Eq for NumberVariable {}
1059
1060impl Hash for NumberVariable {
1061    fn hash<H: Hasher>(&self, state: &mut H) {
1062        self.as_key().as_ref().hash(state);
1063    }
1064}
1065
1066impl Serialize for NumberVariable {
1067    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1068    where
1069        S: Serializer,
1070    {
1071        serializer.serialize_str(self.as_key().as_ref())
1072    }
1073}
1074
1075impl<'de> Deserialize<'de> for NumberVariable {
1076    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1077    where
1078        D: Deserializer<'de>,
1079    {
1080        let value = String::deserialize(deserializer)?;
1081        Self::from_key(&value).map_err(serde::de::Error::custom)
1082    }
1083}
1084
1085#[cfg(feature = "schema")]
1086impl JsonSchema for NumberVariable {
1087    fn schema_name() -> std::borrow::Cow<'static, str> {
1088        "NumberVariable".into()
1089    }
1090
1091    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1092        schemars::json_schema!({
1093            "type": "string",
1094            "description": "Known number variable keyword or custom kebab-case identifier."
1095        })
1096    }
1097}
1098
1099/// Number rendering forms.
1100#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1101#[cfg_attr(feature = "schema", derive(JsonSchema))]
1102#[serde(rename_all = "lowercase")]
1103pub enum NumberForm {
1104    #[default]
1105    Numeric,
1106    Ordinal,
1107    Roman,
1108}
1109
1110fn normalize_kind_key(value: &str) -> Option<String> {
1111    let mut normalized = String::new();
1112    let mut pending_dash = false;
1113
1114    for ch in value.trim().chars() {
1115        if ch.is_ascii_alphanumeric() {
1116            if pending_dash && !normalized.is_empty() {
1117                normalized.push('-');
1118            }
1119            normalized.push(ch.to_ascii_lowercase());
1120            pending_dash = false;
1121        } else if !normalized.is_empty() {
1122            pending_dash = true;
1123        }
1124    }
1125
1126    if normalized.is_empty() {
1127        None
1128    } else {
1129        Some(normalized)
1130    }
1131}
1132
1133/// Label rendering forms.
1134#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
1135#[cfg_attr(feature = "schema", derive(JsonSchema))]
1136#[serde(rename_all = "kebab-case")]
1137pub enum LabelForm {
1138    Long,
1139    #[default]
1140    Short,
1141    Symbol,
1142}
1143
1144/// A simple variable component (DOI, ISBN, URL, etc.).
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 TemplateVariable {
1149    pub variable: SimpleVariable,
1150    #[serde(flatten)]
1151    pub rendering: Rendering,
1152    /// Structured link options (DOI, URL).
1153    #[serde(skip_serializing_if = "Option::is_none")]
1154    pub links: Option<crate::options::LinksConfig>,
1155
1156    /// Custom user-defined fields for extensions.
1157    #[serde(skip_serializing_if = "Option::is_none")]
1158    pub custom: Option<HashMap<String, serde_json::Value>>,
1159}
1160
1161/// A locale message call inside a citation or bibliography template.
1162///
1163/// The style chooses the message ID and supplies structured argument sources;
1164/// the active locale owns the natural-language realization in its `messages`
1165/// map.
1166#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1167#[cfg_attr(feature = "schema", derive(JsonSchema))]
1168#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1169pub struct TemplateMessage {
1170    /// Locale message ID to evaluate, such as `pattern.accessed-date`.
1171    pub message: String,
1172    /// Optional term form used when `message` addresses a `term.*` locale item.
1173    #[serde(skip_serializing_if = "Option::is_none")]
1174    pub form: Option<TermForm>,
1175    /// Explicit grammatical gender override for term-backed message selection.
1176    #[serde(skip_serializing_if = "Option::is_none")]
1177    pub gender: Option<GrammaticalGender>,
1178    /// Named argument sources pre-rendered before message evaluation.
1179    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1180    pub args: HashMap<String, MessageArgSource>,
1181    #[serde(flatten, default)]
1182    pub rendering: Rendering,
1183
1184    /// Custom user-defined fields for extensions.
1185    #[serde(skip_serializing_if = "Option::is_none")]
1186    pub custom: Option<HashMap<String, serde_json::Value>>,
1187}
1188
1189/// A structured source for one named locale-message argument.
1190#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1191#[cfg_attr(feature = "schema", derive(JsonSchema))]
1192#[serde(untagged)]
1193pub enum MessageArgSource {
1194    /// A literal string argument.
1195    Literal { literal: String },
1196    /// A rendered contributor argument.
1197    Contributor(TemplateContributor),
1198    /// A rendered date argument.
1199    Date(TemplateDate),
1200    /// A rendered group argument.
1201    Group(TemplateGroup),
1202    /// A rendered title argument.
1203    Title(TemplateTitle),
1204    /// A rendered number argument.
1205    Number(TemplateNumber),
1206    /// A rendered variable argument.
1207    Variable(TemplateVariable),
1208    /// A rendered locale term argument.
1209    Term(TemplateTerm),
1210}
1211
1212impl MessageArgSource {
1213    /// Convert this argument source into a normal template component when it
1214    /// should be rendered through the standard component pipeline.
1215    #[must_use]
1216    pub fn as_template_component(&self) -> Option<TemplateComponent> {
1217        match self {
1218            Self::Literal { .. } => None,
1219            Self::Contributor(component) => Some(TemplateComponent::Contributor(component.clone())),
1220            Self::Date(component) => Some(TemplateComponent::Date(component.clone())),
1221            Self::Group(component) => Some(TemplateComponent::Group(component.clone())),
1222            Self::Title(component) => Some(TemplateComponent::Title(component.clone())),
1223            Self::Number(component) => Some(TemplateComponent::Number(component.clone())),
1224            Self::Variable(component) => Some(TemplateComponent::Variable(component.clone())),
1225            Self::Term(component) => Some(TemplateComponent::Term(component.clone())),
1226        }
1227    }
1228}
1229
1230/// Simple string variables.
1231///
1232/// Use `variable:` for string passthrough fields, even when the field name is
1233/// also present in [`NumberVariable`]. For example, `variable: volume` keeps the
1234/// source value as plain text, while `number: volume` opts into numeric
1235/// formatting behavior.
1236#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1237#[cfg_attr(feature = "schema", derive(JsonSchema))]
1238#[serde(rename_all = "kebab-case")]
1239#[non_exhaustive]
1240pub enum SimpleVariable {
1241    #[default]
1242    Doi,
1243    Isbn,
1244    Issn,
1245    Url,
1246    Pmid,
1247    Pmcid,
1248    Abstract,
1249    Note,
1250    Annote,
1251    Keyword,
1252    Genre,
1253    RawGenre,
1254    Medium,
1255    RawMedium,
1256    Source,
1257    Status,
1258    Archive,
1259    ArchiveLocation,
1260    ArchiveName,
1261    ArchivePlace,
1262    ArchiveCollection,
1263    ArchiveCollectionId,
1264    ArchiveSeries,
1265    ArchiveBox,
1266    ArchiveFolder,
1267    ArchiveItem,
1268    ArchiveUrl,
1269    EprintId,
1270    EprintServer,
1271    EprintClass,
1272    Publisher,
1273    PublisherPlace,
1274    OriginalPublisher,
1275    OriginalPublisherPlace,
1276    EventTitle,
1277    EventPlace,
1278    Dimensions,
1279    References,
1280    Scale,
1281    Version,
1282    Locator,
1283    ContainerTitleShort,
1284    Authority,
1285    Code,
1286    Reporter,
1287    Page,
1288    Section,
1289    Volume,
1290    Number,
1291    DocketNumber,
1292    PatentNumber,
1293    StandardNumber,
1294    ReportNumber,
1295    AdsBibcode,
1296}
1297
1298/// A term component for rendering locale-specific text.
1299#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1300#[cfg_attr(feature = "schema", derive(JsonSchema))]
1301#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1302pub struct TemplateTerm {
1303    /// Which term to render.
1304    pub term: GeneralTerm,
1305    /// Form: long (default), short, or symbol.
1306    #[serde(skip_serializing_if = "Option::is_none")]
1307    pub form: Option<TermForm>,
1308    /// Explicit grammatical gender override for term selection.
1309    #[serde(skip_serializing_if = "Option::is_none")]
1310    pub gender: Option<GrammaticalGender>,
1311    #[serde(flatten, default)]
1312    pub rendering: Rendering,
1313
1314    /// Custom user-defined fields for extensions.
1315    #[serde(skip_serializing_if = "Option::is_none")]
1316    pub custom: Option<HashMap<String, serde_json::Value>>,
1317}
1318
1319/// Where a [`TemplateTypeLabel`] resolves its text from.
1320///
1321/// `#[non_exhaustive]` with a single variant today: the label always
1322/// describes the reference's own type. Kept as an enum (rather than a bare
1323/// marker field) so a future label source can be added without a schema
1324/// break.
1325#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
1326#[cfg_attr(feature = "schema", derive(JsonSchema))]
1327#[serde(rename_all = "kebab-case")]
1328#[non_exhaustive]
1329pub enum TypeLabelSource {
1330    /// Resolve the label from the reference's own type: prefer its
1331    /// `genre`/`medium`, falling back to a locale term keyed by `ref_type`.
1332    #[default]
1333    ReferenceType,
1334}
1335
1336/// A localized label describing the reference's own type (e.g. "Dataset",
1337/// "Classical work"), resolved from `genre`/`medium` with a locale-term
1338/// fallback keyed by `ref_type`.
1339///
1340/// Emits only the resolved term text — wrap it in `wrap: brackets` (or any
1341/// other `Rendering` option) at the style level to match a particular
1342/// style's presentation, the same as any other component.
1343///
1344/// See `docs/specs/TYPE_CLASSIFICATION_CENTRALIZATION.md`.
1345#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1346#[cfg_attr(feature = "schema", derive(JsonSchema))]
1347#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1348pub struct TemplateTypeLabel {
1349    /// The label's text source. Currently always `reference-type`.
1350    #[serde(rename = "type-label")]
1351    pub type_label: TypeLabelSource,
1352    #[serde(flatten, default)]
1353    pub rendering: Rendering,
1354
1355    /// Custom user-defined fields for extensions.
1356    #[serde(skip_serializing_if = "Option::is_none")]
1357    pub custom: Option<HashMap<String, serde_json::Value>>,
1358}
1359
1360/// A group component for grouping multiple components with a delimiter,
1361/// matching CSL 1.0 `<group>` semantics.
1362#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1363#[cfg_attr(feature = "schema", derive(JsonSchema))]
1364#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1365pub struct TemplateGroup {
1366    pub group: Vec<TemplateComponent>,
1367    /// Optional field-presence condition that controls whether the group renders.
1368    #[serde(skip_serializing_if = "Option::is_none")]
1369    pub render_when: Option<TemplateGroupCondition>,
1370    #[serde(skip_serializing_if = "Option::is_none")]
1371    pub delimiter: Option<DelimiterPunctuation>,
1372    #[serde(flatten, default)]
1373    pub rendering: Rendering,
1374
1375    /// Custom user-defined fields for extensions.
1376    #[serde(skip_serializing_if = "Option::is_none")]
1377    pub custom: Option<HashMap<String, serde_json::Value>>,
1378}
1379
1380/// Field-presence condition for rendering a template group.
1381#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
1382#[cfg_attr(feature = "schema", derive(JsonSchema))]
1383#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1384pub struct TemplateGroupCondition {
1385    /// Required field that must be present for the group to render.
1386    #[serde(skip_serializing_if = "Option::is_none")]
1387    pub field_present: Option<TemplateConditionField>,
1388    /// Required field that must be absent for the group to render.
1389    #[serde(skip_serializing_if = "Option::is_none")]
1390    pub field_absent: Option<TemplateConditionField>,
1391}
1392
1393/// Reference fields that can be tested by a template group condition.
1394#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
1395#[cfg_attr(feature = "schema", derive(JsonSchema))]
1396#[serde(rename_all = "kebab-case")]
1397pub enum TemplateConditionField {
1398    /// The primary author contributor.
1399    Author,
1400    /// The editor contributor.
1401    Editor,
1402    /// The recipient contributor.
1403    Recipient,
1404    /// The translator contributor.
1405    Translator,
1406    /// The primary title.
1407    Title,
1408    /// The series or collection title.
1409    CollectionTitle,
1410    /// The issued date.
1411    Issued,
1412    /// The original publication date.
1413    OriginalPublished,
1414    /// The publisher name.
1415    Publisher,
1416    /// The original publisher name (e.g. a reprint's first publisher).
1417    OriginalPublisher,
1418    /// The original publisher place (e.g. a reprint's first place of publication).
1419    OriginalPublisherPlace,
1420    /// The original title (e.g. a translation's title in its source language).
1421    OriginalTitle,
1422    /// The DOI identifier.
1423    Doi,
1424    /// The reference genre or item type label.
1425    Genre,
1426    /// The archive or repository name.
1427    Archive,
1428    /// The archive shelfmark or repository location.
1429    ArchiveLocation,
1430}
1431
1432/// Delimiter punctuation options.
1433#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)]
1434#[serde(rename_all = "kebab-case")]
1435pub enum DelimiterPunctuation {
1436    #[default]
1437    Comma,
1438    Semicolon,
1439    Period,
1440    Colon,
1441    Ampersand,
1442    VerticalLine,
1443    Slash,
1444    Hyphen,
1445    Space,
1446    None,
1447    /// Custom delimiter string (e.g., ": ").
1448    #[serde(untagged)]
1449    Custom(String),
1450}
1451
1452#[cfg(feature = "schema")]
1453impl JsonSchema for DelimiterPunctuation {
1454    fn schema_name() -> std::borrow::Cow<'static, str> {
1455        "DelimiterPunctuation".into()
1456    }
1457
1458    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1459        schemars::json_schema!({"type": "string", "description": "Delimiter punctuation options."})
1460    }
1461}
1462
1463impl DelimiterPunctuation {
1464    /// Convert this delimiter to a string with trailing space.
1465    ///
1466    /// Returns the punctuation followed by a space, except for Space (single space) and None (empty string).
1467    pub fn to_string_with_space(&self) -> String {
1468        match self {
1469            Self::Comma => ", ".to_string(),
1470            Self::Semicolon => "; ".to_string(),
1471            Self::Period => ". ".to_string(),
1472            Self::Colon => ": ".to_string(),
1473            Self::Ampersand => " & ".to_string(),
1474            Self::VerticalLine => " | ".to_string(),
1475            Self::Slash => "/".to_string(),
1476            Self::Hyphen => "-".to_string(),
1477            Self::Space => " ".to_string(),
1478            Self::None => "".to_string(),
1479            Self::Custom(s) => s.clone(),
1480        }
1481    }
1482
1483    /// Parse a delimiter from a CSL 1.0 delimiter string.
1484    ///
1485    /// Handles common patterns like ", ", ": ", etc.
1486    /// Returns the Custom variant for unrecognized delimiters.
1487    pub fn from_csl_string(s: &str) -> Self {
1488        if s == " " {
1489            return Self::Space;
1490        }
1491
1492        let trimmed = s.trim();
1493        if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
1494            return Self::None;
1495        }
1496
1497        match trimmed {
1498            "," => Self::Comma,
1499            ";" => Self::Semicolon,
1500            "." => Self::Period,
1501            ":" => Self::Colon,
1502            "&" => Self::Ampersand,
1503            "|" => Self::VerticalLine,
1504            "/" => Self::Slash,
1505            "-" => Self::Hyphen,
1506            _ => Self::Custom(s.to_string()),
1507        }
1508    }
1509}
1510
1511#[cfg(test)]
1512#[allow(
1513    clippy::unwrap_used,
1514    clippy::expect_used,
1515    clippy::panic,
1516    clippy::indexing_slicing,
1517    clippy::todo,
1518    clippy::unimplemented,
1519    clippy::unreachable,
1520    clippy::get_unwrap,
1521    reason = "Panicking is acceptable and often desired in tests."
1522)]
1523mod tests {
1524    use super::*;
1525
1526    #[test]
1527    fn test_contributor_deserialization() {
1528        let yaml = r#"
1529contributor: author
1530form: long
1531"#;
1532        let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1533        assert_eq!(comp.contributor, ContributorRole::Author);
1534        assert_eq!(comp.form, ContributorForm::Long);
1535    }
1536
1537    #[test]
1538    fn test_contributor_name_order_family_first_except_last_deserialization() {
1539        let yaml = r#"
1540contributor: author
1541form: long
1542name-order: family-first-except-last
1543"#;
1544        let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1545        assert_eq!(comp.name_order, Some(NameOrder::FamilyFirstExceptLast));
1546    }
1547
1548    #[test]
1549    fn test_template_component_untagged() {
1550        let yaml = r#"
1551- contributor: author
1552  form: short
1553- date: issued
1554  form: year
1555- title: primary
1556"#;
1557        let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1558        assert_eq!(components.len(), 3);
1559
1560        match &components[0] {
1561            TemplateComponent::Contributor(c) => {
1562                assert_eq!(c.contributor, ContributorRole::Author);
1563            }
1564            _ => panic!("Expected Contributor"),
1565        }
1566
1567        match &components[1] {
1568            TemplateComponent::Date(d) => {
1569                assert_eq!(d.date, DateVariable::Issued);
1570            }
1571            _ => panic!("Expected Date"),
1572        }
1573    }
1574
1575    #[test]
1576    fn test_flattened_rendering() {
1577        // Test that rendering options can be specified directly on the component
1578        let yaml = r#"
1579- title: parent-monograph
1580  prefix: "In "
1581  emph: true
1582- date: issued
1583  form: year
1584  wrap: parentheses
1585"#;
1586        let components: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1587        assert_eq!(components.len(), 2);
1588
1589        match &components[0] {
1590            TemplateComponent::Title(t) => {
1591                assert_eq!(t.rendering.prefix, Some("In ".to_string()));
1592                assert_eq!(t.rendering.emph, Some(true));
1593            }
1594            _ => panic!("Expected Title"),
1595        }
1596
1597        match &components[1] {
1598            TemplateComponent::Date(d) => {
1599                assert_eq!(
1600                    d.rendering.wrap,
1601                    Some(WrapConfig {
1602                        punctuation: WrapPunctuation::Parentheses,
1603                        inner_prefix: None,
1604                        inner_suffix: None,
1605                    })
1606                );
1607            }
1608            _ => panic!("Expected Date"),
1609        }
1610    }
1611
1612    #[test]
1613    fn test_number_variable_custom_normalizes_manual_construction() {
1614        let number = NumberVariable::Custom("Reel Label".to_string());
1615
1616        assert_eq!(number.as_key(), "reel-label");
1617        assert_eq!(
1618            number,
1619            serde_yaml::from_str::<NumberVariable>("reel-label")
1620                .expect("custom number variable should parse")
1621        );
1622        assert_eq!(
1623            serde_json::to_string(&number).expect("custom number variable should serialize"),
1624            "\"reel-label\""
1625        );
1626    }
1627
1628    #[test]
1629    fn test_contributor_with_wrap() {
1630        let yaml = r#"
1631contributor: publisher
1632form: short
1633wrap: parentheses
1634"#;
1635        let comp: TemplateContributor = serde_yaml::from_str(yaml).unwrap();
1636        assert_eq!(comp.contributor, ContributorRole::Publisher);
1637        assert_eq!(
1638            comp.rendering.wrap,
1639            Some(WrapConfig {
1640                punctuation: WrapPunctuation::Parentheses,
1641                inner_prefix: None,
1642                inner_suffix: None,
1643            })
1644        );
1645    }
1646
1647    #[test]
1648    fn test_variable_deserialization() {
1649        // Test that `variable: publisher` parses as Variable, not Number
1650        let yaml = "variable: publisher\n";
1651        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1652        match comp {
1653            TemplateComponent::Variable(v) => {
1654                assert_eq!(v.variable, SimpleVariable::Publisher);
1655            }
1656            _ => panic!("Expected Variable(Publisher), got {:?}", comp),
1657        }
1658    }
1659
1660    #[test]
1661    fn test_message_component_deserialization() {
1662        let yaml = r#"
1663message: pattern.in-container
1664args:
1665  container:
1666    group:
1667    - title: parent-monograph
1668      emph: true
1669text-case: capitalize-first
1670"#;
1671        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1672
1673        match comp {
1674            TemplateComponent::Message(message) => {
1675                assert_eq!(message.message, "pattern.in-container");
1676                assert!(matches!(
1677                    message.args.get("container"),
1678                    Some(MessageArgSource::Group(group)) if group.group.len() == 1
1679                        && matches!(
1680                            group.group.first(),
1681                            Some(TemplateComponent::Title(title))
1682                                if title.title == TitleType::ParentMonograph
1683                                    && title.rendering.emph == Some(true)
1684                        )
1685                ));
1686                assert_eq!(
1687                    message.rendering.text_case,
1688                    Some(crate::options::titles::TextCase::CapitalizeFirst)
1689                );
1690            }
1691            _ => panic!("Expected Message component, got {comp:?}"),
1692        }
1693    }
1694
1695    #[test]
1696    fn test_term_backed_message_component_deserializes_form() {
1697        let yaml = r#"
1698message: term.in
1699form: long
1700suffix: ":"
1701"#;
1702        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1703
1704        match comp {
1705            TemplateComponent::Message(message) => {
1706                assert_eq!(message.message, "term.in");
1707                assert_eq!(message.form, Some(TermForm::Long));
1708                assert_eq!(message.rendering.suffix.as_deref(), Some(":"));
1709            }
1710            _ => panic!("Expected Message component, got {comp:?}"),
1711        }
1712    }
1713
1714    #[test]
1715    fn test_group_deserializes_term_backed_message_component_with_form() {
1716        let yaml = r#"
1717group:
1718- message: term.in
1719  form: long
1720  suffix: ":"
1721- title: parent-monograph
1722"#;
1723        let comp: TemplateComponent = serde_yaml::from_str(yaml).unwrap();
1724
1725        match comp {
1726            TemplateComponent::Group(group) => {
1727                assert!(matches!(
1728                    group.group.first(),
1729                    Some(TemplateComponent::Message(message))
1730                        if message.message == "term.in"
1731                            && message.form == Some(TermForm::Long)
1732                            && message.rendering.suffix.as_deref() == Some(":")
1733                ));
1734            }
1735            _ => panic!("Expected Group component, got {comp:?}"),
1736        }
1737    }
1738
1739    #[test]
1740    fn test_variable_array_parsing() {
1741        let yaml = r#"
1742- variable: doi
1743  prefix: "https://doi.org/"
1744- variable: publisher
1745"#;
1746        let comps: Vec<TemplateComponent> = serde_yaml::from_str(yaml).unwrap();
1747        assert_eq!(comps.len(), 2);
1748        match &comps[0] {
1749            TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Doi),
1750            _ => panic!("Expected Variable for doi, got {:?}", comps[0]),
1751        }
1752        match &comps[1] {
1753            TemplateComponent::Variable(v) => assert_eq!(v.variable, SimpleVariable::Publisher),
1754            _ => panic!("Expected Variable for publisher, got {:?}", comps[1]),
1755        }
1756    }
1757
1758    #[test]
1759    fn test_type_selector_default_only_matches_default_context() {
1760        let selector = TypeSelector::Single("default".to_string());
1761        assert!(selector.matches("default"));
1762        assert!(!selector.matches("article-journal"));
1763
1764        let mixed = TypeSelector::Multiple(vec!["default".to_string(), "chapter".to_string()]);
1765        assert!(mixed.matches("default"));
1766        assert!(mixed.matches("chapter"));
1767        assert!(!mixed.matches("book"));
1768    }
1769
1770    #[test]
1771    fn test_template_component_selector_matches_nested_partial_group() {
1772        let component: TemplateComponent = serde_yaml::from_str(
1773            r#"
1774delimiter: ""
1775group:
1776- number: citation-number
1777  wrap:
1778    punctuation: brackets
1779- contributor: author
1780  form: long
1781"#,
1782        )
1783        .unwrap();
1784        let selector = TemplateComponentSelector {
1785            fields: BTreeMap::from([(
1786                "group".to_string(),
1787                serde_json::json!([
1788                    { "number": "citation-number" },
1789                    { "contributor": "author" }
1790                ]),
1791            )]),
1792        };
1793
1794        assert!(selector.matches(&component));
1795    }
1796
1797    #[test]
1798    fn test_delimiter_from_csl_string_normalizes_none_and_trimmed_values() {
1799        assert_eq!(
1800            DelimiterPunctuation::from_csl_string("none"),
1801            DelimiterPunctuation::None
1802        );
1803        assert_eq!(
1804            DelimiterPunctuation::from_csl_string(" none "),
1805            DelimiterPunctuation::None
1806        );
1807        assert_eq!(
1808            DelimiterPunctuation::from_csl_string(" "),
1809            DelimiterPunctuation::Space
1810        );
1811        assert_eq!(
1812            DelimiterPunctuation::from_csl_string(" : "),
1813            DelimiterPunctuation::Colon
1814        );
1815    }
1816}