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}