Skip to main content

zenith_core/parse/
policy.rs

1//! Standalone parsers for config blocks (`diagnostics { … }`, `brand { … }`).
2//!
3//! A Zenith config file (global or local) is a small KDL document whose
4//! meaningful nodes are a top-level `diagnostics { … }` block and/or a
5//! top-level `brand { … }` block, written exactly like their in-document
6//! counterparts:
7//!
8//! ```text
9//! diagnostics {
10//!     allow "layout.off_canvas"
11//!     deny  "font.local"
12//!     warn  "node.unknown_property"
13//! }
14//!
15//! brand {
16//!     colors "#0b1f33" "#ffffff"
17//!     fonts  "Noto Sans"
18//!     weights 400 700
19//! }
20//! ```
21//!
22//! This is NOT a full `.zen` document — there is no `zenith` root node, no
23//! `project`, no `tokens`. Only the `diagnostics` and `brand` blocks are read;
24//! any other top-level node is silently ignored for forward-compatibility,
25//! mirroring the lenient posture used throughout the document transform. A
26//! source with no matching node (including an empty source) yields the
27//! respective default (empty policy / empty contract), which is an identity
28//! pass.
29
30use crate::ast::brand::BrandContract;
31use crate::ast::policy::DiagnosticPolicy;
32use crate::error::{ParseError, ParseErrorCode};
33use crate::parse::transform::{transform_brand_contract, transform_diagnostic_policy};
34
35/// Parse a standalone `diagnostics { … }` KDL config block from raw bytes.
36///
37/// The bytes are decoded and parsed as KDL using the same UTF-8-then-KDL path
38/// as the document parser. The first top-level `diagnostics` node is delegated
39/// to the shared `transform_diagnostic_policy` transform; other top-level
40/// nodes are ignored. A missing `diagnostics` node returns
41/// [`DiagnosticPolicy::default`].
42///
43/// # Errors
44///
45/// Returns a [`ParseError`] if the bytes are not valid UTF-8, are not valid
46/// KDL, or if a recognized `allow`/`deny`/`warn` entry is missing its
47/// diagnostic-code string argument.
48pub fn parse_diagnostic_policy(source: &[u8]) -> Result<DiagnosticPolicy, ParseError> {
49    // Step 1: validate UTF-8 (same contract as `KdlAdapter::parse`).
50    let text = std::str::from_utf8(source).map_err(|e| {
51        ParseError::spanless(
52            ParseErrorCode::NotUtf8,
53            format!("config source is not valid UTF-8: {e}"),
54        )
55    })?;
56
57    // Step 2: parse KDL.
58    let kdl_doc: kdl::KdlDocument = text.parse().map_err(|e: kdl::KdlError| {
59        ParseError::spanless(
60            ParseErrorCode::InvalidKdl,
61            format!("config KDL parse error: {e}"),
62        )
63    })?;
64
65    // Step 3: locate the first top-level `diagnostics` node and transform it.
66    // Absent → empty policy (identity pass).
67    match kdl_doc
68        .nodes()
69        .iter()
70        .find(|n| n.name().value() == "diagnostics")
71    {
72        Some(node) => transform_diagnostic_policy(node),
73        None => Ok(DiagnosticPolicy::default()),
74    }
75}
76
77/// Parse a standalone `brand { … }` KDL config block from raw bytes.
78///
79/// The bytes are decoded and parsed as KDL using the same UTF-8-then-KDL path
80/// as the document parser. The first top-level `brand` node is delegated to
81/// the shared `transform_brand_contract` transform; other top-level nodes
82/// are ignored. A missing `brand` node returns [`BrandContract::default`].
83///
84/// # Errors
85///
86/// Returns a [`ParseError`] if the bytes are not valid UTF-8, are not valid
87/// KDL, or if a recognized category entry (`colors`, `fonts`, `weights`)
88/// contains a value of the wrong type (e.g. a non-string color or a
89/// out-of-range weight integer).
90pub fn parse_brand_contract(source: &[u8]) -> Result<BrandContract, ParseError> {
91    // Step 1: validate UTF-8 (same contract as `KdlAdapter::parse`).
92    let text = std::str::from_utf8(source).map_err(|e| {
93        ParseError::spanless(
94            ParseErrorCode::NotUtf8,
95            format!("config source is not valid UTF-8: {e}"),
96        )
97    })?;
98
99    // Step 2: parse KDL.
100    let kdl_doc: kdl::KdlDocument = text.parse().map_err(|e: kdl::KdlError| {
101        ParseError::spanless(
102            ParseErrorCode::InvalidKdl,
103            format!("config KDL parse error: {e}"),
104        )
105    })?;
106
107    // Step 3: locate the first top-level `brand` node and transform it.
108    // Absent → empty contract (identity pass).
109    match kdl_doc.nodes().iter().find(|n| n.name().value() == "brand") {
110        Some(node) => transform_brand_contract(node),
111        None => Ok(BrandContract::default()),
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::ast::policy::PolicyVerb;
119
120    #[test]
121    fn parses_allow_deny_warn_block() {
122        let src = br#"diagnostics {
123            allow "layout.off_canvas"
124            deny  "font.local"
125            warn  "node.unknown_property"
126        }"#;
127        let policy = parse_diagnostic_policy(src).expect("must parse");
128        assert_eq!(policy.entries.len(), 3);
129        assert_eq!(
130            policy.verb_for("layout.off_canvas"),
131            Some(&PolicyVerb::Allow)
132        );
133        assert_eq!(policy.verb_for("font.local"), Some(&PolicyVerb::Deny));
134        assert_eq!(
135            policy.verb_for("node.unknown_property"),
136            Some(&PolicyVerb::Warn)
137        );
138    }
139
140    #[test]
141    fn empty_source_is_default_policy() {
142        let policy = parse_diagnostic_policy(b"").expect("empty must parse");
143        assert!(policy.entries.is_empty());
144    }
145
146    #[test]
147    fn no_diagnostics_node_is_default_policy() {
148        // A valid KDL document with unrelated top-level nodes → empty policy.
149        let src = br#"something else=1
150        other "node""#;
151        let policy = parse_diagnostic_policy(src).expect("must parse");
152        assert!(policy.entries.is_empty());
153    }
154
155    #[test]
156    fn malformed_kdl_is_error() {
157        let src = b"diagnostics {{{ not valid kdl";
158        let err = parse_diagnostic_policy(src).expect_err("must fail");
159        assert_eq!(err.code, ParseErrorCode::InvalidKdl);
160    }
161
162    #[test]
163    fn entry_missing_code_is_error() {
164        // `deny` with no quoted code string is a hard parse error.
165        let src = br#"diagnostics {
166            deny
167        }"#;
168        let err = parse_diagnostic_policy(src).expect_err("missing code must fail");
169        assert_eq!(err.code, ParseErrorCode::InvalidPropertyValue);
170    }
171
172    #[test]
173    fn last_wins_across_entries() {
174        let src = br#"diagnostics {
175            deny "node.unknown_property"
176            warn "node.unknown_property"
177        }"#;
178        let policy = parse_diagnostic_policy(src).expect("must parse");
179        assert_eq!(
180            policy.verb_for("node.unknown_property"),
181            Some(&PolicyVerb::Warn)
182        );
183    }
184
185    // ── parse_brand_contract ─────────────────────────────────────────────────
186
187    #[test]
188    fn brand_contract_parses_all_categories() {
189        let src = br##"brand {
190            colors "#0b1f33" "#ffffff"
191            fonts  "Noto Sans" "Roboto"
192            weights 400 700
193        }"##;
194        let contract = parse_brand_contract(src).expect("must parse");
195        assert_eq!(
196            contract.allowed_colors,
197            Some(vec!["#0b1f33".to_owned(), "#ffffff".to_owned()])
198        );
199        assert_eq!(
200            contract.allowed_fonts,
201            Some(vec!["Noto Sans".to_owned(), "Roboto".to_owned()])
202        );
203        assert_eq!(contract.allowed_weights, Some(vec![400u32, 700u32]));
204    }
205
206    #[test]
207    fn brand_contract_absent_node_is_default() {
208        let contract = parse_brand_contract(b"").expect("empty must parse");
209        assert!(contract.is_empty(), "absent brand node must yield default");
210    }
211
212    #[test]
213    fn brand_contract_no_brand_node_is_default() {
214        let src = br#"diagnostics {
215            allow "token.unused"
216        }"#;
217        let contract = parse_brand_contract(src).expect("must parse");
218        assert!(
219            contract.is_empty(),
220            "source with only diagnostics node must yield default brand contract"
221        );
222    }
223
224    #[test]
225    fn brand_contract_malformed_kdl_is_error() {
226        let src = b"brand {{{ not valid kdl";
227        let err = parse_brand_contract(src).expect_err("must fail");
228        assert_eq!(err.code, ParseErrorCode::InvalidKdl);
229    }
230
231    #[test]
232    fn brand_contract_partial_categories_only_colors() {
233        let src = br##"brand {
234            colors "#ff0000"
235        }"##;
236        let contract = parse_brand_contract(src).expect("must parse");
237        assert_eq!(contract.allowed_colors, Some(vec!["#ff0000".to_owned()]));
238        assert!(
239            contract.allowed_fonts.is_none(),
240            "absent fonts must remain None (unconstrained)"
241        );
242        assert!(
243            contract.allowed_weights.is_none(),
244            "absent weights must remain None (unconstrained)"
245        );
246    }
247}