1use 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#[derive(Debug)]
21pub struct CmdOutput {
22 pub stdout: String,
24 pub exit_code: u8,
26}
27
28pub fn run(src: &str, project_dir: Option<&Path>, json: bool, flags: &CliPolicyFlags) -> CmdOutput {
49 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 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 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
115fn 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
141fn 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#[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}