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}