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" "bg.glow" "bg.rim"
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", Some("bg.glow")),
131            Some(&PolicyVerb::Allow)
132        );
133        assert_eq!(
134            policy.verb_for("layout.off_canvas", Some("bg.rim")),
135            Some(&PolicyVerb::Allow)
136        );
137        assert_eq!(policy.verb_for("layout.off_canvas", Some("shape.1")), None);
138        assert_eq!(policy.verb_for("font.local", None), Some(&PolicyVerb::Deny));
139        assert_eq!(
140            policy.verb_for("node.unknown_property", None),
141            Some(&PolicyVerb::Warn)
142        );
143    }
144
145    #[test]
146    fn empty_source_is_default_policy() {
147        let policy = parse_diagnostic_policy(b"").expect("empty must parse");
148        assert!(policy.entries.is_empty());
149    }
150
151    #[test]
152    fn no_diagnostics_node_is_default_policy() {
153        // A valid KDL document with unrelated top-level nodes → empty policy.
154        let src = br#"something else=1
155        other "node""#;
156        let policy = parse_diagnostic_policy(src).expect("must parse");
157        assert!(policy.entries.is_empty());
158    }
159
160    #[test]
161    fn malformed_kdl_is_error() {
162        let src = b"diagnostics {{{ not valid kdl";
163        let err = parse_diagnostic_policy(src).expect_err("must fail");
164        assert_eq!(err.code, ParseErrorCode::InvalidKdl);
165    }
166
167    #[test]
168    fn entry_missing_code_is_error() {
169        // `deny` with no quoted code string is a hard parse error.
170        let src = br#"diagnostics {
171            deny
172        }"#;
173        let err = parse_diagnostic_policy(src).expect_err("missing code must fail");
174        assert_eq!(err.code, ParseErrorCode::InvalidPropertyValue);
175    }
176
177    #[test]
178    fn subject_argument_must_be_string() {
179        let src = br#"diagnostics {
180            allow "layout.off_canvas" 1
181        }"#;
182        let err = parse_diagnostic_policy(src).expect_err("invalid subject must fail");
183        assert_eq!(err.code, ParseErrorCode::InvalidPropertyValue);
184    }
185
186    #[test]
187    fn subject_property_is_rejected() {
188        let src = br#"diagnostics {
189            allow "layout.off_canvas" subject="bg.glow"
190        }"#;
191        let err = parse_diagnostic_policy(src).expect_err("subject property must fail");
192        assert_eq!(err.code, ParseErrorCode::InvalidPropertyValue);
193    }
194
195    #[test]
196    fn last_wins_across_entries() {
197        let src = br#"diagnostics {
198            deny "node.unknown_property"
199            warn "node.unknown_property"
200        }"#;
201        let policy = parse_diagnostic_policy(src).expect("must parse");
202        assert_eq!(
203            policy.verb_for("node.unknown_property", None),
204            Some(&PolicyVerb::Warn)
205        );
206    }
207
208    // ── parse_brand_contract ─────────────────────────────────────────────────
209
210    #[test]
211    fn brand_contract_parses_all_categories() {
212        let src = br##"brand {
213            colors "#0b1f33" "#ffffff"
214            fonts  "Noto Sans" "Roboto"
215            weights 400 700
216        }"##;
217        let contract = parse_brand_contract(src).expect("must parse");
218        assert_eq!(
219            contract.allowed_colors,
220            Some(vec!["#0b1f33".to_owned(), "#ffffff".to_owned()])
221        );
222        assert_eq!(
223            contract.allowed_fonts,
224            Some(vec!["Noto Sans".to_owned(), "Roboto".to_owned()])
225        );
226        assert_eq!(contract.allowed_weights, Some(vec![400u32, 700u32]));
227    }
228
229    #[test]
230    fn brand_contract_absent_node_is_default() {
231        let contract = parse_brand_contract(b"").expect("empty must parse");
232        assert!(contract.is_empty(), "absent brand node must yield default");
233    }
234
235    #[test]
236    fn brand_contract_no_brand_node_is_default() {
237        let src = br#"diagnostics {
238            allow "token.unused"
239        }"#;
240        let contract = parse_brand_contract(src).expect("must parse");
241        assert!(
242            contract.is_empty(),
243            "source with only diagnostics node must yield default brand contract"
244        );
245    }
246
247    #[test]
248    fn brand_contract_malformed_kdl_is_error() {
249        let src = b"brand {{{ not valid kdl";
250        let err = parse_brand_contract(src).expect_err("must fail");
251        assert_eq!(err.code, ParseErrorCode::InvalidKdl);
252    }
253
254    #[test]
255    fn brand_contract_partial_categories_only_colors() {
256        let src = br##"brand {
257            colors "#ff0000"
258        }"##;
259        let contract = parse_brand_contract(src).expect("must parse");
260        assert_eq!(contract.allowed_colors, Some(vec!["#ff0000".to_owned()]));
261        assert!(
262            contract.allowed_fonts.is_none(),
263            "absent fonts must remain None (unconstrained)"
264        );
265        assert!(
266            contract.allowed_weights.is_none(),
267            "absent weights must remain None (unconstrained)"
268        );
269    }
270}