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 /// Forward-compat: captures unknown keys when an older engine reads a
75 /// style produced by a newer schema. Empty by default; treated as a
76 /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
77 #[serde(
78 flatten,
79 default,
80 skip_serializing_if = "std::collections::BTreeMap::is_empty"
81 )]
82 #[cfg_attr(feature = "schema", schemars(skip))]
83 pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
84}
85
86impl Style {
87 /// Parse a Citum style from a YAML string, preserving raw YAML for
88 /// null-aware overlay merging during base resolution.
89 ///
90 /// Preferred over `serde_yaml::from_str` when the style extends a base,
91 /// so that `ibid: ~` and similar null overrides correctly clear inherited values.
92 ///
93 /// # Errors
94 ///
95 /// Returns a serde error if YAML parsing or deserialization fails.
96 pub fn from_yaml_str(s: &str) -> Result<Self, serde_yaml::Error> {
97 let raw: serde_yaml::Value = serde_yaml::from_str(s)?;
98 super::diagnostics::validate_raw_style(&raw).map_err(serde_yaml::Error::custom)?;
99 let mut style: Style = serde_yaml::from_value(raw.clone())?;
100 style.raw_yaml = Some(raw);
101 style
102 .validate_resource_limits()
103 .map_err(serde_yaml::Error::custom)?;
104 Ok(style)
105 }
106
107 /// Apply scoped citation and bibliography option overrides to this style.
108 ///
109 /// Translates typed option values (label mode, label wrap, repeated-author
110 /// rendering, date position, title terminator) into concrete template mutations.
111 /// Call this after mutating `bibliography.options` at runtime — e.g. after
112 /// applying per-document overrides — so that template state stays consistent
113 /// with the option values.
114 pub fn apply_scoped_options(&mut self) {
115 crate::options::scoped::apply_scoped_style_options(self);
116 }
117
118 /// Merge a partial overlay style over this style in place; overlay fields win.
119 ///
120 /// Overlay merging is typed and matches `extends` inheritance for the fields it supports:
121 /// - `info`, `templates`, `options`, and `custom` are merged (overlay wins for `Some` fields / keys).
122 /// - `citation` / `bibliography` are deep-merged; explicit YAML `~` can clear inherited fields when
123 /// `overlay.raw_yaml` is populated (e.g. via `Style::from_yaml_bytes`).
124 ///
125 /// The caller is responsible for calling [`apply_scoped_options`](Self::apply_scoped_options)
126 /// afterwards if scoped-option side-effects (label-wrap, date-position, etc.) are needed.
127 pub fn apply_overlay(&mut self, overlay: &Style) {
128 super::overlay::merge_style_overlay(self, overlay);
129 }
130
131 /// Parse a Citum style from YAML bytes, preserving raw YAML for
132 /// null-aware overlay merging during preset resolution.
133 ///
134 /// # Errors
135 ///
136 /// Returns a serde error if YAML parsing or deserialization fails.
137 pub fn from_yaml_bytes(bytes: &[u8]) -> Result<Self, serde_yaml::Error> {
138 let raw: serde_yaml::Value = serde_yaml::from_slice(bytes)?;
139 super::diagnostics::validate_raw_style(&raw).map_err(serde_yaml::Error::custom)?;
140 let mut style: Style = serde_yaml::from_value(raw.clone())?;
141 style.raw_yaml = Some(raw);
142 style
143 .validate_resource_limits()
144 .map_err(serde_yaml::Error::custom)?;
145 Ok(style)
146 }
147}