Skip to main content

citum_schema_style/style/
model.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! The Citum style model.
7
8use std::collections::HashMap;
9
10#[cfg(feature = "schema")]
11use schemars::JsonSchema;
12use serde::de::Error as _;
13use serde::{Deserialize, Serialize};
14
15#[allow(unused_imports, reason = "Referenced by intra-doc links.")]
16use crate::ResolutionError;
17use crate::style_base;
18use crate::{BibliographySpec, CitationSpec, Config, SchemaVersion, StyleInfo, Template};
19
20/// The new Citum Style model.
21///
22/// This is the target schema for Citum, featuring declarative options
23/// and simple template components instead of procedural conditionals.
24#[derive(Debug, Default, Deserialize, Serialize, Clone)]
25#[cfg_attr(feature = "schema", derive(JsonSchema))]
26#[serde(rename_all = "kebab-case")]
27pub struct Style {
28    /// Style schema version.
29    #[serde(default)]
30    pub version: SchemaVersion,
31    /// Style metadata.
32    #[serde(default)]
33    pub info: StyleInfo,
34    /// Named reusable templates.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub templates: Option<HashMap<String, Template>>,
37    /// Global style options.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub options: Option<Config>,
40    /// Citation specification.
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub citation: Option<CitationSpec>,
43    /// Bibliography specification.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub bibliography: Option<BibliographySpec>,
46    /// Custom user-defined fields for extensions.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub custom: Option<HashMap<String, serde_json::Value>>,
49    /// Extends a base style, with optional local overrides.
50    ///
51    /// When present, the base [`StyleReference`](style_base::StyleReference) is resolved and the local
52    /// overrides are merged before any further processing. Explicit `options`,
53    /// `citation`, and `bibliography` keys at the same document level take
54    /// precedence over the resolved base.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub extends: Option<style_base::StyleReference>,
57    /// Optional content-addressed integrity pin for the parent style referenced
58    /// by [`extends`](Self::extends).
59    ///
60    /// When present, the resolver verifies that the SHA-256 of the fetched
61    /// parent matches this CIDv1 string before merging. Mismatches abort
62    /// resolution with [`ResolutionError::IntegrityFailure`]. Absent means
63    /// "no integrity check" — appropriate for `file://` parents under user
64    /// control or trusted local registries.
65    #[serde(rename = "extends-pin", skip_serializing_if = "Option::is_none")]
66    pub extends_pin: Option<String>,
67    /// Raw YAML captured when the style was loaded via [`Style::from_yaml_str`]
68    /// or [`Style::from_yaml_bytes`]. Used during style resolution for
69    /// null-aware overlay merging (e.g., `ibid: ~` correctly clears an
70    /// inherited preset value). Absent in programmatically-constructed styles.
71    #[cfg_attr(feature = "schema", schemars(skip))]
72    #[serde(skip, default)]
73    pub raw_yaml: Option<serde_yaml::Value>,
74    /// Chain-merged authored `citation.options` / `bibliography.options`
75    /// mappings, captured at parse time and maintained through `extends`
76    /// resolution. Basis for the runtime scope cascade's field-level merge
77    /// (see [`crate::options::cascade::ScopedRawOptions`]). Empty in
78    /// programmatically-constructed styles, which fall back to the typed
79    /// whole-block merge.
80    ///
81    /// Public like [`Self::raw_yaml`] and [`Self::unknown_fields`]: other
82    /// workspace crates construct `Style` via `Style { .. } .. Default::default()`,
83    /// which requires every field visible at the call site, so a `pub(crate)`
84    /// field would break those construction sites rather than only external
85    /// struct-literal callers.
86    #[cfg_attr(feature = "schema", schemars(skip))]
87    #[serde(skip, default)]
88    pub scoped_raw_options: crate::options::cascade::ScopedRawOptions,
89    /// Forward-compat: captures unknown keys when an older engine reads a
90    /// style produced by a newer schema. Empty by default; treated as a
91    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
92    #[serde(
93        flatten,
94        default,
95        skip_serializing_if = "std::collections::BTreeMap::is_empty"
96    )]
97    #[cfg_attr(feature = "schema", schemars(skip))]
98    pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
99}
100
101impl Style {
102    /// Parse a Citum style from a YAML string, preserving raw YAML for
103    /// null-aware overlay merging during base resolution.
104    ///
105    /// Preferred over `serde_yaml::from_str` when the style extends a base,
106    /// so that `ibid: ~` and similar null overrides correctly clear inherited values.
107    ///
108    /// # Errors
109    ///
110    /// Returns a serde error if YAML parsing or deserialization fails.
111    pub fn from_yaml_str(s: &str) -> Result<Self, serde_yaml::Error> {
112        let raw: serde_yaml::Value = serde_yaml::from_str(s)?;
113        Self::from_raw_value(raw).map_err(StyleDocumentError::into_yaml_error)
114    }
115
116    /// Apply scoped citation and bibliography option overrides to this style.
117    ///
118    /// Translates typed option values (label mode, label wrap, repeated-author
119    /// rendering, date position, title terminator) into concrete template mutations.
120    /// Call this after mutating `bibliography.options` at runtime — e.g. after
121    /// applying per-document overrides — so that template state stays consistent
122    /// with the option values.
123    pub fn apply_scoped_options(&mut self) {
124        crate::options::scoped::apply_scoped_style_options(self);
125    }
126
127    /// Merge a partial overlay style over this style in place; overlay fields win.
128    ///
129    /// Overlay merging is typed and matches `extends` inheritance for the fields it supports:
130    /// - `info`, `templates`, `options`, and `custom` are merged (overlay wins for `Some` fields / keys).
131    /// - `citation` / `bibliography` are deep-merged; explicit YAML `~` can clear inherited fields when
132    ///   `overlay.raw_yaml` is populated (e.g. via `Style::from_yaml_bytes`).
133    ///
134    /// The caller is responsible for calling [`apply_scoped_options`](Self::apply_scoped_options)
135    /// afterwards if scoped-option side-effects (label-wrap, date-position, etc.) are needed.
136    pub fn apply_overlay(&mut self, overlay: &Style) {
137        super::overlay::merge_style_overlay(self, overlay);
138    }
139
140    /// Parse a Citum style from YAML bytes, preserving raw YAML for
141    /// null-aware overlay merging during preset resolution.
142    ///
143    /// # Errors
144    ///
145    /// Returns a serde error if YAML parsing or deserialization fails.
146    pub fn from_yaml_bytes(bytes: &[u8]) -> Result<Self, serde_yaml::Error> {
147        let raw: serde_yaml::Value = serde_yaml::from_slice(bytes)?;
148        Self::from_raw_value(raw).map_err(StyleDocumentError::into_yaml_error)
149    }
150
151    /// Parse a Citum style from bytes in any [`StyleDocumentFormat`], preserving
152    /// a format-neutral raw value tree for null-aware overlay merging.
153    ///
154    /// This is the canonical entry point for every style load path — file,
155    /// store, registry, CLI conversion, and server resolution — so that
156    /// explicit-`null` inherited-field clearing (see [`Style::apply_overlay`])
157    /// behaves identically regardless of load path or wire format. JSON and
158    /// YAML documents parse directly into the same generic value tree used by
159    /// [`Style::from_yaml_bytes`]; CBOR documents are decoded the same way but
160    /// are rejected if any map uses a non-string key, since the raw-tree
161    /// presence lookups used by overlay merging key on string field names.
162    ///
163    /// # Errors
164    ///
165    /// Returns [`StyleDocumentError`] if the bytes cannot be decoded in the
166    /// requested format, if a CBOR document contains a non-string map key, or
167    /// if the decoded style fails schema or resource-limit validation.
168    pub fn from_document_bytes(
169        bytes: &[u8],
170        format: StyleDocumentFormat,
171    ) -> Result<Self, StyleDocumentError> {
172        let raw: serde_yaml::Value = match format {
173            StyleDocumentFormat::Yaml => serde_yaml::from_slice(bytes)?,
174            StyleDocumentFormat::Json => serde_json::from_slice(bytes)?,
175            StyleDocumentFormat::Cbor => {
176                let raw: serde_yaml::Value = ciborium::de::from_reader(bytes)
177                    .map_err(|e| StyleDocumentError::Cbor(e.to_string()))?;
178                reject_non_string_keys(&raw).map_err(StyleDocumentError::Cbor)?;
179                raw
180            }
181        };
182        Self::from_raw_value(raw)
183    }
184
185    /// Shared tail of [`Style::from_yaml_str`], [`Style::from_yaml_bytes`], and
186    /// [`Style::from_document_bytes`]: validate the raw tree, deserialize the
187    /// typed style from it, stamp `raw_yaml`, then validate resource limits.
188    ///
189    /// The `serde_yaml::from_value` step always uses the real
190    /// [`StyleDocumentError::Yaml`] variant (never collapsed to a string),
191    /// regardless of which wire format the raw tree originated from — the
192    /// tree is already unified into `serde_yaml::Value` by the time this
193    /// runs, so this deserialize step is always a `serde_yaml` operation.
194    fn from_raw_value(raw: serde_yaml::Value) -> Result<Self, StyleDocumentError> {
195        super::diagnostics::validate_raw_style(&raw).map_err(StyleDocumentError::Validation)?;
196        let mut style: Style = serde_yaml::from_value(raw.clone())?;
197        style.raw_yaml = Some(raw);
198        style.scoped_raw_options = crate::options::cascade::ScopedRawOptions::capture(&style);
199        style
200            .validate_resource_limits()
201            .map_err(StyleDocumentError::Validation)?;
202        Ok(style)
203    }
204}
205
206/// Serialization format of a raw style document, used by
207/// [`Style::from_document_bytes`] to select the right decoder.
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub enum StyleDocumentFormat {
210    /// YAML document.
211    Yaml,
212    /// JSON document.
213    Json,
214    /// CBOR document. Only string-keyed maps are supported.
215    Cbor,
216}
217
218/// Error parsing a style document in any [`StyleDocumentFormat`].
219#[derive(Debug)]
220pub enum StyleDocumentError {
221    /// Failure decoding a YAML document, or deserializing the typed [`Style`]
222    /// from the generic raw tree — which applies regardless of whether that
223    /// tree originated from YAML, JSON, or CBOR, since the tree is always
224    /// unified into `serde_yaml::Value` before this step runs.
225    Yaml(serde_yaml::Error),
226    /// Failure decoding a JSON document.
227    Json(serde_json::Error),
228    /// Failure decoding a CBOR document, or a non-string map key was found.
229    Cbor(String),
230    /// The decoded style failed schema or resource-limit validation.
231    Validation(String),
232}
233
234impl std::fmt::Display for StyleDocumentError {
235    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236        match self {
237            StyleDocumentError::Yaml(e) => write!(f, "yaml error: {e}"),
238            StyleDocumentError::Json(e) => write!(f, "json error: {e}"),
239            StyleDocumentError::Cbor(e) => write!(f, "cbor error: {e}"),
240            StyleDocumentError::Validation(e) => write!(f, "invalid style: {e}"),
241        }
242    }
243}
244
245impl std::error::Error for StyleDocumentError {
246    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
247        match self {
248            StyleDocumentError::Yaml(e) => Some(e),
249            StyleDocumentError::Json(e) => Some(e),
250            StyleDocumentError::Cbor(_) | StyleDocumentError::Validation(_) => None,
251        }
252    }
253}
254
255impl From<serde_yaml::Error> for StyleDocumentError {
256    fn from(e: serde_yaml::Error) -> Self {
257        StyleDocumentError::Yaml(e)
258    }
259}
260
261impl From<serde_json::Error> for StyleDocumentError {
262    fn from(e: serde_json::Error) -> Self {
263        StyleDocumentError::Json(e)
264    }
265}
266
267impl StyleDocumentError {
268    /// Convert into a `serde_yaml::Error` for callers with a YAML-only
269    /// public signature ([`Style::from_yaml_str`], [`Style::from_yaml_bytes`]).
270    ///
271    /// The `Yaml` variant unwraps directly, preserving the original error
272    /// and its source chain. `Json`/`Cbor` cannot structurally occur on
273    /// those callers' paths (they never decode JSON or CBOR), so those arms
274    /// only exist to keep this conversion total; `Validation` has no
275    /// underlying serde error to preserve, so it round-trips through
276    /// [`serde::de::Error::custom`].
277    fn into_yaml_error(self) -> serde_yaml::Error {
278        match self {
279            StyleDocumentError::Yaml(e) => e,
280            StyleDocumentError::Json(e) => serde_yaml::Error::custom(e),
281            StyleDocumentError::Cbor(msg) | StyleDocumentError::Validation(msg) => {
282                serde_yaml::Error::custom(msg)
283            }
284        }
285    }
286}
287
288/// Reject a raw value tree containing a mapping keyed by anything other than
289/// a string, recursively. CBOR permits non-string map keys; the overlay
290/// null-clear lookups in `style/overlay.rs` key on string field names, so a
291/// non-string-keyed map would silently fail to match rather than error.
292fn reject_non_string_keys(value: &serde_yaml::Value) -> Result<(), String> {
293    match value {
294        serde_yaml::Value::Mapping(map) => {
295            for (key, val) in map {
296                if !matches!(key, serde_yaml::Value::String(_)) {
297                    return Err(format!(
298                        "CBOR style document uses a non-string map key ({key:?}); \
299                         only string-keyed maps are supported"
300                    ));
301                }
302                reject_non_string_keys(val)?;
303            }
304            Ok(())
305        }
306        serde_yaml::Value::Sequence(seq) => seq.iter().try_for_each(reject_non_string_keys),
307        serde_yaml::Value::Tagged(tagged) => reject_non_string_keys(&tagged.value),
308        _ => Ok(()),
309    }
310}