1use crate::schema::*;
6
7#[derive(Debug, Clone)]
9pub struct ValidationError {
10 pub path: String,
11 pub message: String,
12}
13
14impl std::fmt::Display for ValidationError {
15 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16 write!(f, "{}: {}", self.path, self.message)
17 }
18}
19
20impl std::error::Error for ValidationError {}
21
22pub trait Validate {
24 fn validate(&self, path: &str) -> Vec<ValidationError>;
25}
26
27impl Validate for UiResponse {
28 fn validate(&self, path: &str) -> Vec<ValidationError> {
29 let mut errors = Vec::new();
30
31 if self.components.is_empty() {
32 errors.push(ValidationError {
33 path: path.to_string(),
34 message: "UiResponse must have at least one component".to_string(),
35 });
36 }
37
38 for (i, component) in self.components.iter().enumerate() {
39 errors.extend(component.validate(&format!("{}.components[{}]", path, i)));
40 }
41
42 errors
43 }
44}
45
46impl Validate for Text {
47 fn validate(&self, path: &str) -> Vec<ValidationError> {
48 let mut errors = Vec::new();
49 if self.content.is_empty() {
50 errors.push(ValidationError {
51 path: format!("{}.content", path),
52 message: "Text content cannot be empty".to_string(),
53 });
54 }
55 errors
56 }
57}
58
59impl Validate for Button {
60 fn validate(&self, path: &str) -> Vec<ValidationError> {
61 let mut errors = Vec::new();
62 if self.label.is_empty() {
63 errors.push(ValidationError {
64 path: format!("{}.label", path),
65 message: "Button label cannot be empty".to_string(),
66 });
67 }
68 if self.action_id.is_empty() {
69 errors.push(ValidationError {
70 path: format!("{}.action_id", path),
71 message: "Button action_id cannot be empty".to_string(),
72 });
73 }
74 errors
75 }
76}
77
78impl Validate for TextInput {
79 fn validate(&self, path: &str) -> Vec<ValidationError> {
80 let mut errors = Vec::new();
81 if self.name.is_empty() {
82 errors.push(ValidationError {
83 path: format!("{}.name", path),
84 message: "TextInput name cannot be empty".to_string(),
85 });
86 }
87 if let (Some(min), Some(max)) = (self.min_length, self.max_length)
88 && min > max
89 {
90 errors.push(ValidationError {
91 path: format!("{}.min_length", path),
92 message: "min_length cannot be greater than max_length".to_string(),
93 });
94 }
95 errors
96 }
97}
98
99impl Validate for NumberInput {
100 fn validate(&self, path: &str) -> Vec<ValidationError> {
101 let mut errors = Vec::new();
102 if self.name.is_empty() {
103 errors.push(ValidationError {
104 path: format!("{}.name", path),
105 message: "NumberInput name cannot be empty".to_string(),
106 });
107 }
108 if let (Some(min), Some(max)) = (self.min, self.max)
109 && min > max
110 {
111 errors.push(ValidationError {
112 path: format!("{}.min", path),
113 message: "min cannot be greater than max".to_string(),
114 });
115 }
116 errors
117 }
118}
119
120impl Validate for Select {
121 fn validate(&self, path: &str) -> Vec<ValidationError> {
122 let mut errors = Vec::new();
123 if self.name.is_empty() {
124 errors.push(ValidationError {
125 path: format!("{}.name", path),
126 message: "Select name cannot be empty".to_string(),
127 });
128 }
129 if self.options.is_empty() {
130 errors.push(ValidationError {
131 path: format!("{}.options", path),
132 message: "Select must have at least one option".to_string(),
133 });
134 }
135 errors
136 }
137}
138
139impl Validate for Table {
140 fn validate(&self, path: &str) -> Vec<ValidationError> {
141 let mut errors = Vec::new();
142 if self.columns.is_empty() {
143 errors.push(ValidationError {
144 path: format!("{}.columns", path),
145 message: "Table must have at least one column".to_string(),
146 });
147 }
148 if self.page_size == Some(0) {
149 errors.push(ValidationError {
150 path: format!("{}.page_size", path),
151 message: "Table page_size must be greater than zero".to_string(),
152 });
153 }
154 if let Some(source) = &self.data_source {
155 errors.extend(validate_data_source(
156 source,
157 &format!("{}.data_source", path),
158 ));
159 }
160 errors
161 }
162}
163
164impl Validate for Chart {
165 fn validate(&self, path: &str) -> Vec<ValidationError> {
166 let mut errors = Vec::new();
167 if self.data.is_empty() && self.data_source.is_none() {
168 errors.push(ValidationError {
169 path: format!("{}.data", path),
170 message: "Chart must have data".to_string(),
171 });
172 }
173 if self.y_keys.is_empty() {
174 errors.push(ValidationError {
175 path: format!("{}.y_keys", path),
176 message: "Chart must have at least one y_key".to_string(),
177 });
178 }
179 if self.x_key.trim().is_empty() {
180 errors.push(ValidationError {
181 path: format!("{}.x_key", path),
182 message: "Chart x_key cannot be empty".to_string(),
183 });
184 }
185 if let Some(source) = &self.data_source {
186 errors.extend(validate_data_source(
187 source,
188 &format!("{}.data_source", path),
189 ));
190 }
191 errors
192 }
193}
194
195fn validate_data_source(source: &DataSource, path: &str) -> Vec<ValidationError> {
196 let mut errors = Vec::new();
197 if !source.binding_path.starts_with('/') {
198 errors.push(ValidationError {
199 path: format!("{}.binding_path", path),
200 message: "Data source binding_path must be an absolute JSON Pointer".to_string(),
201 });
202 }
203 if matches!(source.refresh, DataRefresh::Interval { secs: 0 }) {
204 errors.push(ValidationError {
205 path: format!("{}.refresh.secs", path),
206 message: "Data source refresh interval must be greater than zero".to_string(),
207 });
208 }
209 if source.ttl == Some(0) {
210 errors.push(ValidationError {
211 path: format!("{}.ttl", path),
212 message: "Data source ttl must be greater than zero".to_string(),
213 });
214 }
215 errors
216}
217
218impl Validate for Card {
219 fn validate(&self, path: &str) -> Vec<ValidationError> {
220 let mut errors = Vec::new();
221 for (i, child) in self.content.iter().enumerate() {
222 errors.extend(child.validate(&format!("{}.content[{}]", path, i)));
223 }
224 if let Some(footer) = &self.footer {
225 for (i, child) in footer.iter().enumerate() {
226 errors.extend(child.validate(&format!("{}.footer[{}]", path, i)));
227 }
228 }
229 errors
230 }
231}
232
233impl Validate for Modal {
234 fn validate(&self, path: &str) -> Vec<ValidationError> {
235 let mut errors = Vec::new();
236 for (i, child) in self.content.iter().enumerate() {
237 errors.extend(child.validate(&format!("{}.content[{}]", path, i)));
238 }
239 errors
240 }
241}
242
243impl Validate for Stack {
244 fn validate(&self, path: &str) -> Vec<ValidationError> {
245 let mut errors = Vec::new();
246 for (i, child) in self.children.iter().enumerate() {
247 errors.extend(child.validate(&format!("{}.children[{}]", path, i)));
248 }
249 errors
250 }
251}
252
253impl Validate for Grid {
254 fn validate(&self, path: &str) -> Vec<ValidationError> {
255 let mut errors = Vec::new();
256 for (i, child) in self.children.iter().enumerate() {
257 errors.extend(child.validate(&format!("{}.children[{}]", path, i)));
258 }
259 errors
260 }
261}
262
263impl Validate for Tabs {
264 fn validate(&self, path: &str) -> Vec<ValidationError> {
265 let mut errors = Vec::new();
266 if self.tabs.is_empty() {
267 errors.push(ValidationError {
268 path: format!("{}.tabs", path),
269 message: "Tabs must have at least one tab".to_string(),
270 });
271 }
272 errors
273 }
274}
275
276impl Validate for Scene3d {
277 fn validate(&self, path: &str) -> Vec<ValidationError> {
278 let mut errors = Vec::new();
279 if self.objects.is_empty() || self.objects.len() > 64 {
280 errors.push(ValidationError {
281 path: format!("{}.objects", path),
282 message: "Scene3d requires between 1 and 64 objects".to_string(),
283 });
284 }
285 if !(240..=960).contains(&self.height) {
286 errors.push(ValidationError {
287 path: format!("{}.height", path),
288 message: "Scene3d height must be between 240 and 960 pixels".to_string(),
289 });
290 }
291 if !(20.0..=90.0).contains(&self.camera.fov) {
292 errors.push(ValidationError {
293 path: format!("{}.camera.fov", path),
294 message: "Scene3d camera fov must be between 20 and 90 degrees".to_string(),
295 });
296 }
297 let model_count = self
298 .objects
299 .iter()
300 .filter(|object| matches!(object, SceneObject::Model(_)))
301 .count();
302 if model_count > 8 {
303 errors.push(ValidationError {
304 path: format!("{}.objects", path),
305 message: "Scene3d supports at most 8 model assets".to_string(),
306 });
307 }
308 for (index, object) in self.objects.iter().enumerate() {
309 let (id, scale) = match object {
310 SceneObject::Primitive(value) => (&value.id, value.scale),
311 SceneObject::Model(value) => {
312 if value.asset_id.trim().is_empty() || value.asset_id.contains(['/', '\\']) {
313 errors.push(ValidationError {
314 path: format!("{}.objects[{}].asset_id", path, index),
315 message: "model asset_id must reference one approved kit asset"
316 .to_string(),
317 });
318 }
319 (&value.id, value.scale)
320 }
321 };
322 if id.trim().is_empty() {
323 errors.push(ValidationError {
324 path: format!("{}.objects[{}].id", path, index),
325 message: "scene object id must not be empty".to_string(),
326 });
327 }
328 if scale
329 .iter()
330 .any(|value| !value.is_finite() || *value <= 0.0 || *value > 100.0)
331 {
332 errors.push(ValidationError {
333 path: format!("{}.objects[{}].scale", path, index),
334 message: "scene object scale values must be finite and between 0 and 100"
335 .to_string(),
336 });
337 }
338 }
339 errors
340 }
341}
342
343impl Validate for Component {
344 fn validate(&self, path: &str) -> Vec<ValidationError> {
345 match self {
346 Component::Text(t) => t.validate(path),
347 Component::Button(b) => b.validate(path),
348 Component::TextInput(t) => t.validate(path),
349 Component::NumberInput(n) => n.validate(path),
350 Component::Select(s) => s.validate(path),
351 Component::Table(t) => t.validate(path),
352 Component::Chart(c) => c.validate(path),
353 Component::Scene3d(scene) => scene.validate(path),
354 Component::Card(c) => c.validate(path),
355 Component::Modal(m) => m.validate(path),
356 Component::Stack(s) => s.validate(path),
357 Component::Grid(g) => g.validate(path),
358 Component::Tabs(t) => t.validate(path),
359 _ => Vec::new(),
361 }
362 }
363}
364
365pub fn validate_ui_response(ui: &UiResponse) -> Result<(), Vec<ValidationError>> {
367 let errors = ui.validate("UiResponse");
368 if errors.is_empty() {
369 Ok(())
370 } else {
371 Err(errors)
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378
379 #[test]
380 fn test_empty_response_fails() {
381 let ui = UiResponse::new(vec![]);
382 let result = validate_ui_response(&ui);
383 assert!(result.is_err());
384 }
385
386 #[test]
387 fn test_valid_text_passes() {
388 let ui = UiResponse::new(vec![Component::Text(Text {
389 id: None,
390 content: "Hello".to_string(),
391 variant: TextVariant::Body,
392 })]);
393 let result = validate_ui_response(&ui);
394 assert!(result.is_ok());
395 }
396
397 #[test]
398 fn test_empty_button_label_fails() {
399 let ui = UiResponse::new(vec![Component::Button(Button {
400 id: None,
401 label: "".to_string(),
402 action_id: "click".to_string(),
403 variant: ButtonVariant::Primary,
404 disabled: false,
405 icon: None,
406 })]);
407 let result = validate_ui_response(&ui);
408 assert!(result.is_err());
409 }
410
411 #[test]
412 fn test_scene_3d_enforces_object_budget_and_height() {
413 let ui = UiResponse::new(vec![Component::Scene3d(Scene3d {
414 id: Some("network".to_string()),
415 title: Some("Network".to_string()),
416 description: None,
417 height: 120,
418 background: SceneBackground::Surface,
419 camera: SceneCamera::default(),
420 objects: vec![],
421 auto_rotate: false,
422 controls: true,
423 fallback: Some("Use the table view.".to_string()),
424 })]);
425 let errors = validate_ui_response(&ui).expect_err("invalid scene should fail");
426 assert!(errors.iter().any(|error| error.path.ends_with(".objects")));
427 assert!(errors.iter().any(|error| error.path.ends_with(".height")));
428 }
429}