Skip to main content

adk_ui/
validation.rs

1//! Validation for UI components
2//!
3//! Server-side validation to catch malformed UiResponse before sending to client.
4
5use crate::schema::*;
6
7/// Validation error for UI components
8#[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
22/// Trait for validatable UI components
23pub 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            // Components with no additional validation constraints
360            _ => Vec::new(),
361        }
362    }
363}
364
365/// Validate a UiResponse and return Result
366pub 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}