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    /// Applies structural scoped options such as group delimiters, date position,
119    /// title terminators, and repeated-author rendering. Label mode and label wrap
120    /// remain runtime presentation settings and do not mutate authored templates.
121    pub fn apply_scoped_options(&mut self) {
122        crate::options::scoped::apply_scoped_style_options(self);
123    }
124
125    /// Merge a partial overlay style over this style in place; overlay fields win.
126    ///
127    /// Overlay merging is typed and matches `extends` inheritance for the fields it supports:
128    /// - `info`, `templates`, `options`, and `custom` are merged (overlay wins for `Some` fields / keys).
129    /// - `citation` / `bibliography` are deep-merged; explicit YAML `~` can clear inherited fields when
130    ///   `overlay.raw_yaml` is populated (e.g. via `Style::from_yaml_bytes`).
131    ///
132    /// The caller is responsible for calling [`apply_scoped_options`](Self::apply_scoped_options)
133    /// afterwards if structural scoped-option side-effects (date position, title
134    /// terminator, etc.) are needed.
135    pub fn apply_overlay(&mut self, overlay: &Style) {
136        super::overlay::merge_style_overlay(self, overlay);
137    }
138
139    /// Parse a Citum style from YAML bytes, preserving raw YAML for
140    /// null-aware overlay merging during preset resolution.
141    ///
142    /// # Errors
143    ///
144    /// Returns a serde error if YAML parsing or deserialization fails.
145    pub fn from_yaml_bytes(bytes: &[u8]) -> Result<Self, serde_yaml::Error> {
146        let raw: serde_yaml::Value = serde_yaml::from_slice(bytes)?;
147        Self::from_raw_value(raw).map_err(StyleDocumentError::into_yaml_error)
148    }
149
150    /// Parse a Citum style from bytes in any [`StyleDocumentFormat`], preserving
151    /// a format-neutral raw value tree for null-aware overlay merging.
152    ///
153    /// This is the canonical entry point for every style load path — file,
154    /// store, registry, CLI conversion, and server resolution — so that
155    /// explicit-`null` inherited-field clearing (see [`Style::apply_overlay`])
156    /// behaves identically regardless of load path or wire format. JSON and
157    /// YAML documents parse directly into the same generic value tree used by
158    /// [`Style::from_yaml_bytes`]; CBOR documents are decoded the same way but
159    /// are rejected if any map uses a non-string key, since the raw-tree
160    /// presence lookups used by overlay merging key on string field names.
161    ///
162    /// # Errors
163    ///
164    /// Returns [`StyleDocumentError`] if the bytes cannot be decoded in the
165    /// requested format, if a CBOR document contains a non-string map key, or
166    /// if the decoded style fails schema or resource-limit validation.
167    pub fn from_document_bytes(
168        bytes: &[u8],
169        format: StyleDocumentFormat,
170    ) -> Result<Self, StyleDocumentError> {
171        let raw: serde_yaml::Value = match format {
172            StyleDocumentFormat::Yaml => serde_yaml::from_slice(bytes)?,
173            StyleDocumentFormat::Json => serde_json::from_slice(bytes)?,
174            StyleDocumentFormat::Cbor => {
175                let raw: serde_yaml::Value = ciborium::de::from_reader(bytes)
176                    .map_err(|e| StyleDocumentError::Cbor(e.to_string()))?;
177                reject_non_string_keys(&raw).map_err(StyleDocumentError::Cbor)?;
178                raw
179            }
180        };
181        Self::from_raw_value(raw)
182    }
183
184    /// Shared tail of [`Style::from_yaml_str`], [`Style::from_yaml_bytes`], and
185    /// [`Style::from_document_bytes`]: validate the raw tree, deserialize the
186    /// typed style from it, stamp `raw_yaml`, then validate resource limits.
187    ///
188    /// The `serde_yaml::from_value` step always uses the real
189    /// [`StyleDocumentError::Yaml`] variant (never collapsed to a string),
190    /// regardless of which wire format the raw tree originated from — the
191    /// tree is already unified into `serde_yaml::Value` by the time this
192    /// runs, so this deserialize step is always a `serde_yaml` operation.
193    fn from_raw_value(raw: serde_yaml::Value) -> Result<Self, StyleDocumentError> {
194        super::diagnostics::validate_raw_style(&raw).map_err(StyleDocumentError::Validation)?;
195        let mut style: Style = serde_yaml::from_value(raw.clone())?;
196        style.raw_yaml = Some(raw);
197        style.scoped_raw_options = crate::options::cascade::ScopedRawOptions::capture(&style);
198        style
199            .validate_resource_limits()
200            .map_err(StyleDocumentError::Validation)?;
201        Ok(style)
202    }
203}
204
205/// Serialization format of a raw style document, used by
206/// [`Style::from_document_bytes`] to select the right decoder.
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub enum StyleDocumentFormat {
209    /// YAML document.
210    Yaml,
211    /// JSON document.
212    Json,
213    /// CBOR document. Only string-keyed maps are supported.
214    Cbor,
215}
216
217/// Error parsing a style document in any [`StyleDocumentFormat`].
218#[derive(Debug)]
219pub enum StyleDocumentError {
220    /// Failure decoding a YAML document, or deserializing the typed [`Style`]
221    /// from the generic raw tree — which applies regardless of whether that
222    /// tree originated from YAML, JSON, or CBOR, since the tree is always
223    /// unified into `serde_yaml::Value` before this step runs.
224    Yaml(serde_yaml::Error),
225    /// Failure decoding a JSON document.
226    Json(serde_json::Error),
227    /// Failure decoding a CBOR document, or a non-string map key was found.
228    Cbor(String),
229    /// The decoded style failed schema or resource-limit validation.
230    Validation(String),
231}
232
233impl std::fmt::Display for StyleDocumentError {
234    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235        match self {
236            StyleDocumentError::Yaml(e) => write!(f, "yaml error: {e}"),
237            StyleDocumentError::Json(e) => write!(f, "json error: {e}"),
238            StyleDocumentError::Cbor(e) => write!(f, "cbor error: {e}"),
239            StyleDocumentError::Validation(e) => write!(f, "invalid style: {e}"),
240        }
241    }
242}
243
244impl std::error::Error for StyleDocumentError {
245    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
246        match self {
247            StyleDocumentError::Yaml(e) => Some(e),
248            StyleDocumentError::Json(e) => Some(e),
249            StyleDocumentError::Cbor(_) | StyleDocumentError::Validation(_) => None,
250        }
251    }
252}
253
254impl From<serde_yaml::Error> for StyleDocumentError {
255    fn from(e: serde_yaml::Error) -> Self {
256        StyleDocumentError::Yaml(e)
257    }
258}
259
260impl From<serde_json::Error> for StyleDocumentError {
261    fn from(e: serde_json::Error) -> Self {
262        StyleDocumentError::Json(e)
263    }
264}
265
266impl StyleDocumentError {
267    /// Convert into a `serde_yaml::Error` for callers with a YAML-only
268    /// public signature ([`Style::from_yaml_str`], [`Style::from_yaml_bytes`]).
269    ///
270    /// The `Yaml` variant unwraps directly, preserving the original error
271    /// and its source chain. `Json`/`Cbor` cannot structurally occur on
272    /// those callers' paths (they never decode JSON or CBOR), so those arms
273    /// only exist to keep this conversion total; `Validation` has no
274    /// underlying serde error to preserve, so it round-trips through
275    /// [`serde::de::Error::custom`].
276    fn into_yaml_error(self) -> serde_yaml::Error {
277        match self {
278            StyleDocumentError::Yaml(e) => e,
279            StyleDocumentError::Json(e) => serde_yaml::Error::custom(e),
280            StyleDocumentError::Cbor(msg) | StyleDocumentError::Validation(msg) => {
281                serde_yaml::Error::custom(msg)
282            }
283        }
284    }
285}
286
287/// Reject a raw value tree containing a mapping keyed by anything other than
288/// a string, recursively. CBOR permits non-string map keys; the overlay
289/// null-clear lookups in `style/overlay.rs` key on string field names, so a
290/// non-string-keyed map would silently fail to match rather than error.
291fn reject_non_string_keys(value: &serde_yaml::Value) -> Result<(), String> {
292    match value {
293        serde_yaml::Value::Mapping(map) => {
294            for (key, val) in map {
295                if !matches!(key, serde_yaml::Value::String(_)) {
296                    return Err(format!(
297                        "CBOR style document uses a non-string map key ({key:?}); \
298                         only string-keyed maps are supported"
299                    ));
300                }
301                reject_non_string_keys(val)?;
302            }
303            Ok(())
304        }
305        serde_yaml::Value::Sequence(seq) => seq.iter().try_for_each(reject_non_string_keys),
306        serde_yaml::Value::Tagged(tagged) => reject_non_string_keys(&tagged.value),
307        _ => Ok(()),
308    }
309}