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