Skip to main content

zenith_cli/commands/
validate.rs

1//! Pure logic for `zenith validate`.
2//!
3//! The public entry point [`run`] operates entirely on in-memory source text;
4//! the caller is responsible for all filesystem I/O.
5
6use std::path::Path;
7
8use zenith_core::{KdlAdapter, KdlSource, Severity, merge_brand_contract, validate_with_policy};
9
10use crate::commands::render::{
11    collect_image_dimension_diagnostics, collect_missing_asset_diagnostics,
12};
13use crate::commands::serialize_pretty;
14use crate::config::{CliPolicyFlags, load_global_and_local, merge_policy};
15use crate::json_types::{DiagnosticJson, ValidateOutput};
16
17// ── Result type ───────────────────────────────────────────────────────────────
18
19/// The outcome of a validate run.
20#[derive(Debug)]
21pub struct CmdOutput {
22    /// Text to write to stdout.
23    pub stdout: String,
24    /// Exit code: 0 = no errors, 1 = validation errors, 2 = parse/io error.
25    pub exit_code: u8,
26}
27
28// ── Public entry point ────────────────────────────────────────────────────────
29
30/// Validate `src` and return formatted output.
31///
32/// When `project_dir` is `Some` (the `.zen` file's parent directory), each
33/// declared asset's file is checked for existence and a hard `asset.missing`
34/// Error diagnostic is added for any that are absent, and that directory is the
35/// starting point for the local `.zenith.kdl` config walk-up. When `None`, no
36/// asset files are checked and no local config is discovered.
37///
38/// The effective diagnostic policy is `merge_policy(global, local, in_file,
39/// flags)` — global/local config plus the document's own `diagnostics` block
40/// plus the `--allow/--warn/--deny` flags — applied once via
41/// [`validate_with_policy`]. With no config files and no flags the merged policy
42/// is identical to the document's in-file policy, so output is unchanged.
43///
44/// - Parse errors and config-load errors produce `exit_code = 2`.
45/// - Documents with at least one error-severity diagnostic produce
46///   `exit_code = 1`.
47/// - Clean documents produce `exit_code = 0`.
48pub fn run(src: &str, project_dir: Option<&Path>, json: bool, flags: &CliPolicyFlags) -> CmdOutput {
49    // Resolve config policy and brand contract ───────────────────────────────
50    // Global config is always consulted; local config is walked up from the
51    // document's directory when known. A load error is a hard exit-2 failure.
52    let (global, local, global_brand, local_brand) = match load_global_and_local(project_dir) {
53        Ok(quad) => quad,
54        Err(msg) => return config_error(&msg, json),
55    };
56
57    // Parse ─────────────────────────────────────────────────────────────────
58    let doc = match KdlAdapter.parse(src.as_bytes()) {
59        Ok(d) => d,
60        Err(e) => {
61            let msg = if json {
62                let out = ValidateOutput {
63                    schema: "zenith-validate-v1",
64                    valid: false,
65                    diagnostics: vec![DiagnosticJson {
66                        code: "parse.error".to_owned(),
67                        severity: "error".to_owned(),
68                        message: e.message.clone(),
69                        subject_id: None,
70                    }],
71                };
72                serialize_pretty(&out)
73            } else {
74                format!("error[parse.error]: {}", e.message)
75            };
76            return CmdOutput {
77                stdout: msg,
78                exit_code: 2,
79            };
80        }
81    };
82
83    // Validate ───────────────────────────────────────────────────────────────
84    // Policy: global ++ local ++ in-file ++ CLI flags (last-wins).
85    // Brand:  global → local → in-file (per-category override, higher wins).
86    let merged = merge_policy(&global, &local, &doc.diagnostic_policy, flags);
87    let effective_brand = merge_brand_contract(
88        &merge_brand_contract(&global_brand, &local_brand),
89        &doc.brand_contract,
90    );
91    let mut diagnostics = validate_with_policy(&doc, &merged, &effective_brand).diagnostics;
92    if let Some(dir) = project_dir {
93        diagnostics.extend(collect_missing_asset_diagnostics(&doc, dir));
94        diagnostics.extend(collect_image_dimension_diagnostics(&doc, dir));
95    }
96    let has_errors = diagnostics.iter().any(|d| d.severity == Severity::Error);
97
98    let stdout = if json {
99        let out = ValidateOutput {
100            schema: "zenith-validate-v1",
101            valid: !has_errors,
102            diagnostics: diagnostics.iter().map(DiagnosticJson::from).collect(),
103        };
104        serialize_pretty(&out)
105    } else {
106        format_human(&diagnostics)
107    };
108
109    CmdOutput {
110        stdout,
111        exit_code: if has_errors { 1 } else { 0 },
112    }
113}
114
115// ── Config-load error ──────────────────────────────────────────────────────────
116
117/// Build an exit-2 [`CmdOutput`] for a config-load failure, in either the JSON
118/// or human output shape (mirroring the parse-error path).
119fn config_error(msg: &str, json: bool) -> CmdOutput {
120    let stdout = if json {
121        let out = ValidateOutput {
122            schema: "zenith-validate-v1",
123            valid: false,
124            diagnostics: vec![DiagnosticJson {
125                code: "config.error".to_owned(),
126                severity: "error".to_owned(),
127                message: msg.to_owned(),
128                subject_id: None,
129            }],
130        };
131        serialize_pretty(&out)
132    } else {
133        format!("error[config.error]: {msg}")
134    };
135    CmdOutput {
136        stdout,
137        exit_code: 2,
138    }
139}
140
141// ── Human-readable formatter ──────────────────────────────────────────────────
142
143fn format_human(diagnostics: &[zenith_core::Diagnostic]) -> String {
144    if diagnostics.is_empty() {
145        return "ok — no diagnostics".to_owned();
146    }
147    diagnostics
148        .iter()
149        .map(crate::commands::format_diagnostic_line)
150        .collect::<Vec<_>>()
151        .join("\n")
152}
153
154// ── Tests ─────────────────────────────────────────────────────────────────────
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    const VALID_DOC: &str = r##"zenith version=1 {
161  project id="proj.v" name="Validate Test"
162  tokens format="zenith-token-v1" {
163    token id="color.bg" type="color" value="#f8fafc"
164    token id="color.accent" type="color" value="#3b82f6"
165  }
166  styles {}
167  document id="doc.v" title="Validate Test" {
168    page id="page.v" w=(px)320 h=(px)200 {
169      rect id="rect.bg" x=(px)0 y=(px)0 w=(px)320 h=(px)200 fill=(token)"color.bg"
170      rect id="rect.accent" x=(px)40 y=(px)40 w=(px)240 h=(px)120 fill=(token)"color.accent"
171    }
172  }
173}
174"##;
175
176    const DUP_ID_DOC: &str = r##"zenith version=1 {
177  project id="proj.d" name="Dup"
178  tokens format="zenith-token-v1" {
179    token id="color.bg" type="color" value="#ffffff"
180    token id="color.bg" type="color" value="#000000"
181  }
182  styles {}
183  document id="doc.d" title="Dup" {
184    page id="page.d" w=(px)100 h=(px)100 {
185      rect id="rect.d" x=(px)0 y=(px)0 w=(px)100 h=(px)100 fill=(token)"color.bg"
186    }
187  }
188}
189"##;
190
191    #[test]
192    fn valid_doc_exits_zero() {
193        let out = run(VALID_DOC, None, false, &CliPolicyFlags::default());
194        assert_eq!(out.exit_code, 0, "stdout: {}", out.stdout);
195    }
196
197    #[test]
198    fn valid_doc_human_output_is_ok() {
199        let out = run(VALID_DOC, None, false, &CliPolicyFlags::default());
200        assert!(
201            out.stdout.contains("ok"),
202            "expected 'ok' in human output; got: {}",
203            out.stdout
204        );
205    }
206
207    #[test]
208    fn duplicate_id_exits_one() {
209        let out = run(DUP_ID_DOC, None, false, &CliPolicyFlags::default());
210        assert_eq!(out.exit_code, 1, "stdout: {}", out.stdout);
211    }
212
213    #[test]
214    fn duplicate_id_reports_id_duplicate_code() {
215        let out = run(DUP_ID_DOC, None, false, &CliPolicyFlags::default());
216        assert!(
217            out.stdout.contains("id.duplicate") || out.stdout.contains("token.duplicate_id"),
218            "expected duplicate diagnostic code; got: {}",
219            out.stdout
220        );
221    }
222
223    #[test]
224    fn valid_doc_json_has_schema_field() {
225        let out = run(VALID_DOC, None, true, &CliPolicyFlags::default());
226        assert!(
227            out.stdout.contains("zenith-validate-v1"),
228            "JSON must contain schema field; got: {}",
229            out.stdout
230        );
231    }
232
233    #[test]
234    fn valid_doc_json_valid_true() {
235        let out = run(VALID_DOC, None, true, &CliPolicyFlags::default());
236        assert!(
237            out.stdout.contains(r#""valid": true"#),
238            "valid doc JSON must have valid=true; got: {}",
239            out.stdout
240        );
241    }
242
243    #[test]
244    fn parse_error_exits_two() {
245        let out = run("not kdl !!!{{{", None, false, &CliPolicyFlags::default());
246        assert_eq!(out.exit_code, 2, "stdout: {}", out.stdout);
247    }
248}