Skip to main content

citum_schema_style/options/
titles.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6#[cfg(feature = "schema")]
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// Text-case transform applied to title-like fields.
12///
13/// Styles select which transform applies to which field category.
14/// The engine provides the generic primitives; styles own the selection.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[cfg_attr(feature = "schema", derive(JsonSchema))]
17#[serde(rename_all = "kebab-case")]
18pub enum TextCase {
19    /// English headline-style title case (capitalize principal words).
20    Title,
21    /// Generic sentence case (capitalize first word only).
22    Sentence,
23    /// APA-style sentence case: capitalize first word of main title
24    /// and first word after each subtitle boundary.
25    SentenceApa,
26    /// NLM-style sentence case: capitalize first word of main title only;
27    /// subtitles preserve only explicit/protected capitals.
28    SentenceNlm,
29    /// Capitalize the first letter of the value.
30    CapitalizeFirst,
31    /// Transform the entire value to lowercase.
32    Lowercase,
33    /// Transform the entire value to uppercase.
34    Uppercase,
35    /// No transformation; render the value exactly as stored.
36    AsIs,
37}
38
39/// Title config: either a preset name or explicit configuration.
40///
41/// Allows styles to write `titles: apa` as shorthand, or provide
42/// full explicit configuration with field-level overrides.
43#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
44#[cfg_attr(feature = "schema", derive(JsonSchema))]
45#[serde(untagged)]
46pub enum TitlesConfigEntry {
47    /// A named preset (e.g., "apa", "chicago", "humanities", "scientific").
48    Preset(crate::presets::TitlePreset),
49    /// Explicit title configuration.
50    Explicit(Box<TitlesConfig>),
51}
52
53impl Default for TitlesConfigEntry {
54    fn default() -> Self {
55        TitlesConfigEntry::Explicit(Box::default())
56    }
57}
58
59impl TitlesConfigEntry {
60    /// Resolve this entry to a concrete `TitlesConfig`.
61    pub fn resolve(&self) -> TitlesConfig {
62        match self {
63            TitlesConfigEntry::Preset(preset) => preset.config(),
64            TitlesConfigEntry::Explicit(config) => *config.clone(),
65        }
66    }
67}
68
69/// Title formatting configuration by title type.
70#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
71#[cfg_attr(feature = "schema", derive(JsonSchema))]
72#[serde(rename_all = "kebab-case")]
73pub struct TitlesConfig {
74    /// Mapping of reference types to title categories.
75    /// Category keys: monograph, periodical, component.
76    /// Example: { "thesis": "monograph", "article-journal": "periodical" }
77    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
78    pub type_mapping: HashMap<String, String>,
79    /// Formatting for component titles (articles, chapters).
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub component: Option<TitleRendering>,
82    /// Formatting for monograph titles (books).
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub monograph: Option<TitleRendering>,
85    /// Formatting for monograph containers (book containing chapters).
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub container_monograph: Option<TitleRendering>,
88    /// Formatting for periodical titles (journals, magazines).
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub periodical: Option<TitleRendering>,
91    /// Formatting for serial titles (series).
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub serial: Option<TitleRendering>,
94    /// Default formatting for all titles.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub default: Option<TitleRendering>,
97    /// Custom user-defined fields for extensions.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub custom: Option<HashMap<String, serde_json::Value>>,
100    /// Forward-compat: captures unknown keys when an older engine reads a
101    /// style produced by a newer schema. Empty by default; treated as a
102    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
103    #[serde(
104        flatten,
105        default,
106        skip_serializing_if = "std::collections::BTreeMap::is_empty"
107    )]
108    #[cfg_attr(feature = "schema", schemars(skip))]
109    pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
110}
111
112/// Rendering options for titles.
113#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
114#[cfg_attr(feature = "schema", derive(JsonSchema))]
115#[serde(rename_all = "kebab-case")]
116pub struct TitleRendering {
117    /// Text-case transform to apply to this title category.
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub text_case: Option<TextCase>,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub emph: Option<bool>,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub quote: Option<bool>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub strong: Option<bool>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub small_caps: Option<bool>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub prefix: Option<String>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub suffix: Option<String>,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub locale_overrides: Option<HashMap<String, TitleRendering>>,
134    /// Forward-compat: captures unknown keys when an older engine reads a
135    /// style produced by a newer schema. Empty by default; treated as a
136    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
137    #[serde(
138        flatten,
139        default,
140        skip_serializing_if = "std::collections::BTreeMap::is_empty"
141    )]
142    #[cfg_attr(feature = "schema", schemars(skip))]
143    pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
144}
145
146impl TitleRendering {
147    pub fn to_rendering(&self) -> crate::template::Rendering {
148        crate::template::Rendering {
149            text_case: self.text_case,
150            emph: self.emph,
151            quote: self.quote,
152            strong: self.strong,
153            small_caps: self.small_caps,
154            prefix: self.prefix.clone(),
155            suffix: self.suffix.clone(),
156            ..Default::default()
157        }
158    }
159
160    pub fn locale_override(&self, language: Option<&str>) -> Option<&TitleRendering> {
161        let overrides = self.locale_overrides.as_ref()?;
162        let language = language?;
163        overrides.get(language).or_else(|| {
164            language
165                .split('-')
166                .next()
167                .and_then(|tag| overrides.get(tag))
168        })
169    }
170}