Skip to main content

citum_schema_style/style/
resolution.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Style inheritance and resolver integration.
7
8use std::collections::HashSet;
9
10use crate::{ResolutionError, StyleInfo, StyleResolver, options, registry, style_base};
11
12use super::Style;
13use super::overlay::merge_style_overlay;
14
15impl Style {
16    /// Resolve this style into its final effective form by applying base inheritance.
17    ///
18    /// If the `extends` field is present, the base [`StyleBase`](style_base::StyleBase) is loaded
19    /// and any explicit `options`, `citation`, or `bibliography` keys in the current
20    /// style document are merged on top (taking ultimate precedence).
21    ///
22    /// Styles without a base still resolve Template V3 variants and scoped
23    /// options, but do not merge any inherited style data.
24    ///
25    /// # Panics
26    ///
27    /// Panics when style resolution fails. Use [`Style::try_into_resolved`]
28    /// to handle profile-contract and inheritance errors explicitly.
29    #[must_use]
30    #[allow(
31        clippy::panic,
32        reason = "Convenience API for infallible resolution contexts"
33    )]
34    pub fn into_resolved(self) -> Self {
35        self.try_into_resolved()
36            .unwrap_or_else(|err| panic!("style resolution failed: {err}"))
37    }
38
39    /// Resolve this style into its final effective form, returning validation errors.
40    ///
41    /// Unlike [`Style::into_resolved`], this preserves resolution failures as
42    /// structured [`ResolutionError`] values.
43    ///
44    /// # Errors
45    ///
46    /// Returns an error when profile wrappers try to override template-bearing
47    /// structure, when profile capability validation fails, or when inheritance
48    /// loops are detected.
49    pub fn try_into_resolved(self) -> Result<Self, ResolutionError> {
50        self.try_into_resolved_with(None)
51    }
52
53    /// Resolve this style into its final effective form using an optional style resolver.
54    ///
55    /// The resolver is used for URI, registry, and remote `extends` references
56    /// that cannot be satisfied by embedded bases alone.
57    ///
58    /// # Errors
59    ///
60    /// Returns an error when style inheritance or Template V3 variant
61    /// resolution fails.
62    pub fn try_into_resolved_with(
63        self,
64        resolver: Option<&StyleResolver>,
65    ) -> Result<Self, ResolutionError> {
66        self.try_into_resolved_recursive_with_depth(resolver, &mut HashSet::new(), 0)
67    }
68
69    /// Internal recursive resolver with loop protection.
70    ///
71    /// # Panics
72    ///
73    /// Panics when style resolution fails. Use
74    /// [`Style::try_into_resolved_recursive`] to preserve errors.
75    #[must_use]
76    #[allow(
77        clippy::panic,
78        reason = "Convenience API for infallible resolution contexts"
79    )]
80    pub fn into_resolved_recursive(self, visited: &mut HashSet<String>) -> Self {
81        self.try_into_resolved_recursive(visited)
82            .unwrap_or_else(|err| panic!("style resolution failed: {err}"))
83    }
84
85    /// Internal recursive resolver with loop protection.
86    ///
87    /// # Errors
88    ///
89    /// Returns an error when profile wrappers violate the config-only
90    /// contract, when profile capability validation fails, or when
91    /// inheritance loops are detected.
92    pub fn try_into_resolved_recursive(
93        self,
94        visited: &mut HashSet<String>,
95    ) -> Result<Self, ResolutionError> {
96        self.try_into_resolved_recursive_with(None, visited)
97    }
98
99    /// Internal recursive resolver with loop protection and optional external resolver.
100    ///
101    /// # Errors
102    ///
103    /// Returns an error when style inheritance or Template V3 variant
104    /// resolution fails.
105    pub fn try_into_resolved_recursive_with(
106        self,
107        resolver: Option<&StyleResolver>,
108        visited: &mut HashSet<String>,
109    ) -> Result<Self, ResolutionError> {
110        self.try_into_resolved_recursive_with_depth(resolver, visited, 0)
111    }
112
113    /// Internal recursive resolver with depth limit.
114    fn try_into_resolved_recursive_with_depth(
115        self,
116        resolver: Option<&StyleResolver>,
117        visited: &mut HashSet<String>,
118        depth: usize,
119    ) -> Result<Self, ResolutionError> {
120        const MAX_DEPTH: usize = 5;
121
122        // Reject root styles whose declared engine compat range we don't satisfy
123        // before we waste any work on inheritance or template variants.
124        let root_label = self
125            .info
126            .id
127            .as_deref()
128            .or(self.info.title.as_deref())
129            .unwrap_or("<root>");
130        check_citum_version(root_label, &self.info)?;
131
132        let Some(base_ref) = self.extends.clone() else {
133            let mut style = self;
134            crate::template::resolve_style_template_variants(&mut style, None)?;
135            options::scoped::apply_scoped_style_options(&mut style);
136            return Ok(style);
137        };
138
139        if depth >= MAX_DEPTH {
140            let uri = base_ref.key();
141            return Err(ResolutionError::UriResolutionFailed {
142                uri: uri.to_string(),
143                reason: format!("inheritance chain exceeds maximum depth of {MAX_DEPTH}"),
144            });
145        }
146
147        let key = base_ref.key().to_string();
148        if visited.contains(&key) {
149            return Err(ResolutionError::InheritanceLoop { base: key });
150        }
151        visited.insert(key);
152
153        let is_profile = self.resolves_as_profile();
154        let pin = self.extends_pin.clone();
155        let mut effective = match base_ref {
156            style_base::StyleReference::Base(base) => {
157                if pin.is_some() {
158                    return Err(ResolutionError::UriResolutionFailed {
159                        uri: base.key().to_string(),
160                        reason:
161                            "extends-pin is only supported for URI-based parents (https://, cid:); \
162                         builtin StyleBase parents are content-fixed already"
163                                .to_string(),
164                    });
165                }
166                base.try_resolve_with_visited(resolver, visited)?
167            }
168            style_base::StyleReference::Uri(ref uri) => {
169                let base_style = resolve_style_reference_uri(uri, resolver)?;
170                if let Some(ref expected) = pin {
171                    verify_parent_pin(uri, &base_style, expected)?;
172                }
173                base_style.try_into_resolved_recursive_with_depth(resolver, visited, depth + 1)?
174            }
175        };
176        if is_profile {
177            self.validate_profile_shape()?;
178        }
179
180        let inherited_variants = crate::template::inherited_variant_context(&effective);
181        merge_style_overlay(&mut effective, &self);
182        effective.version = self.version;
183        effective.extends = self.extends;
184        effective.extends_pin = self.extends_pin;
185        effective.raw_yaml = self.raw_yaml;
186        crate::template::resolve_style_template_variants(
187            &mut effective,
188            inherited_variants.as_ref(),
189        )?;
190        options::scoped::apply_scoped_style_options(&mut effective);
191        if is_profile {
192            effective.extends = None;
193        }
194
195        Ok(effective)
196    }
197    fn style_kind(&self) -> Option<registry::StyleKind> {
198        let id = self.info.id.as_deref()?;
199        registry::StyleRegistry::load_default()
200            .resolve(id)
201            .and_then(|entry| entry.kind.clone())
202    }
203
204    fn resolves_as_profile(&self) -> bool {
205        self.style_kind() == Some(registry::StyleKind::Profile)
206    }
207}
208
209#[allow(
210    clippy::panic,
211    reason = "Multihash::wrap on a 32-byte SHA-256 digest is infallible by construction"
212)]
213fn schema_compute_style_cid(bytes: &[u8]) -> String {
214    use cid::Cid;
215    use multihash::Multihash;
216    use sha2::{Digest, Sha256};
217
218    const RAW_CODEC: u64 = 0x55;
219    const SHA256_CODE: u64 = 0x12;
220
221    let digest: [u8; 32] = Sha256::digest(bytes).into();
222    let mh = Multihash::<64>::wrap(SHA256_CODE, &digest)
223        .unwrap_or_else(|_| panic!("32-byte SHA-256 digest fits in Multihash<64>"));
224    Cid::new_v1(RAW_CODEC, mh).to_string()
225}
226
227/// Normalize a CID-or-`cid:`-URI to its canonical lowercase string form.
228fn schema_canonicalize_cid(s: &str) -> Result<String, ResolutionError> {
229    use cid::Cid;
230    let trimmed = s.strip_prefix("cid:").unwrap_or(s);
231    let cid: Cid =
232        trimmed
233            .parse()
234            .map_err(|err: cid::Error| ResolutionError::UriResolutionFailed {
235                uri: s.to_string(),
236                reason: format!("invalid CID '{s}': {err}"),
237            })?;
238    Ok(cid.to_string())
239}
240
241/// Verify that the parent style's serialized form matches `expected_pin`.
242///
243/// Re-serializes the parsed `Style` to its canonical YAML form and computes
244/// its CIDv1. Mismatch produces [`ResolutionError::IntegrityFailure`]. This
245/// is a best-effort check at the schema layer; for byte-exact verification
246/// of the originally-fetched bytes, route through
247/// `citum_store::fetch_and_verify_bytes` before parsing.
248fn verify_parent_pin(uri: &str, parent: &Style, expected_pin: &str) -> Result<(), ResolutionError> {
249    let expected = schema_canonicalize_cid(expected_pin)?;
250    let bytes =
251        serde_yaml::to_string(parent).map_err(|err| ResolutionError::UriResolutionFailed {
252            uri: uri.to_string(),
253            reason: format!("re-serialize for extends-pin verification: {err}"),
254        })?;
255    let actual = schema_compute_style_cid(bytes.as_bytes());
256    if actual == expected {
257        Ok(())
258    } else {
259        Err(ResolutionError::IntegrityFailure {
260            uri: uri.to_string(),
261            expected,
262            actual,
263        })
264    }
265}
266
267/// Apply an `info.citum-version` requirement check against the running
268/// engine version (CARGO_PKG_VERSION).
269///
270/// Returns `Ok(())` when the field is absent or the requirement is satisfied;
271/// returns [`ResolutionError::VersionMismatch`] otherwise.
272///
273/// # Errors
274///
275/// Returns [`ResolutionError::VersionMismatch`] when the running engine
276/// version does not satisfy the style's declared `citum-version` requirement.
277/// Returns [`ResolutionError::UriResolutionFailed`] when the requirement
278/// string itself fails to parse as a semver `VersionReq`, or when the
279/// running engine's `CARGO_PKG_VERSION` cannot be parsed (this latter case
280/// is structurally impossible at runtime but is preserved as a typed error
281/// rather than a panic).
282pub fn check_citum_version(uri: &str, info: &StyleInfo) -> Result<(), ResolutionError> {
283    let Some(req_str) = info.citum_version.as_ref() else {
284        return Ok(());
285    };
286    let req =
287        semver::VersionReq::parse(req_str).map_err(|err| ResolutionError::UriResolutionFailed {
288            uri: uri.to_string(),
289            reason: format!("invalid `info.citum-version` requirement '{req_str}': {err}"),
290        })?;
291    let engine_str = env!("CARGO_PKG_VERSION");
292    let engine =
293        semver::Version::parse(engine_str).map_err(|err| ResolutionError::UriResolutionFailed {
294            uri: uri.to_string(),
295            reason: format!("unparseable engine version `{engine_str}`: {err}"),
296        })?;
297    if req.matches(&engine) {
298        Ok(())
299    } else {
300        Err(ResolutionError::VersionMismatch {
301            uri: uri.to_string(),
302            required: req_str.clone(),
303            declared: engine_str.to_string(),
304        })
305    }
306}
307
308fn resolve_style_reference_uri(
309    uri: &str,
310    resolver: Option<&StyleResolver>,
311) -> Result<Style, ResolutionError> {
312    if let Some(resolver) = resolver {
313        let style = resolver
314            .resolve_style(uri)
315            .map_err(|e| ResolutionError::from_resolver_error(uri, e))?;
316        check_citum_version(uri, &style.info)?;
317        return Ok(style);
318    }
319
320    let Some(raw_path) = uri.strip_prefix("file://") else {
321        return Err(ResolutionError::UriResolutionFailed {
322            uri: uri.to_string(),
323            reason: "unsupported scheme; an external style resolver is required".to_string(),
324        });
325    };
326    let path = std::path::Path::new(raw_path);
327    let bytes = std::fs::read(path).map_err(|e| ResolutionError::UriResolutionFailed {
328        uri: uri.to_string(),
329        reason: e.to_string(),
330    })?;
331    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("yaml");
332    let style: Style = match ext {
333        "cbor" => ciborium::de::from_reader(std::io::Cursor::new(&bytes)).map_err(|e| {
334            ResolutionError::UriResolutionFailed {
335                uri: uri.to_string(),
336                reason: e.to_string(),
337            }
338        })?,
339        "json" => {
340            serde_json::from_slice(&bytes).map_err(|e| ResolutionError::UriResolutionFailed {
341                uri: uri.to_string(),
342                reason: e.to_string(),
343            })?
344        }
345        _ => Style::from_yaml_bytes(&bytes).map_err(|e| ResolutionError::UriResolutionFailed {
346            uri: uri.to_string(),
347            reason: e.to_string(),
348        })?,
349    };
350    check_citum_version(uri, &style.info)?;
351    Ok(style)
352}