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 Component {
47    fn validate(&self, path: &str) -> Vec<ValidationError> {
48        let mut errors = Vec::new();
49
50        match self {
51            Component::Text(t) => {
52                if t.content.is_empty() {
53                    errors.push(ValidationError {
54                        path: format!("{}.content", path),
55                        message: "Text content cannot be empty".to_string(),
56                    });
57                }
58            }
59            Component::Button(b) => {
60                if b.label.is_empty() {
61                    errors.push(ValidationError {
62                        path: format!("{}.label", path),
63                        message: "Button label cannot be empty".to_string(),
64                    });
65                }
66                if b.action_id.is_empty() {
67                    errors.push(ValidationError {
68                        path: format!("{}.action_id", path),
69                        message: "Button action_id cannot be empty".to_string(),
70                    });
71                }
72            }
73            Component::TextInput(t) => {
74                if t.name.is_empty() {
75                    errors.push(ValidationError {
76                        path: format!("{}.name", path),
77                        message: "TextInput name cannot be empty".to_string(),
78                    });
79                }
80                if let (Some(min), Some(max)) = (t.min_length, t.max_length) {
81                    if min > max {
82                        errors.push(ValidationError {
83                            path: format!("{}.min_length", path),
84                            message: "min_length cannot be greater than max_length".to_string(),
85                        });
86                    }
87                }
88            }
89            Component::NumberInput(n) => {
90                if n.name.is_empty() {
91                    errors.push(ValidationError {
92                        path: format!("{}.name", path),
93                        message: "NumberInput name cannot be empty".to_string(),
94                    });
95                }
96                if n.min > n.max {
97                    errors.push(ValidationError {
98                        path: format!("{}.min", path),
99                        message: "min cannot be greater than max".to_string(),
100                    });
101                }
102            }
103            Component::Select(s) => {
104                if s.name.is_empty() {
105                    errors.push(ValidationError {
106                        path: format!("{}.name", path),
107                        message: "Select name cannot be empty".to_string(),
108                    });
109                }
110                if s.options.is_empty() {
111                    errors.push(ValidationError {
112                        path: format!("{}.options", path),
113                        message: "Select must have at least one option".to_string(),
114                    });
115                }
116            }
117            Component::Table(t) => {
118                if t.columns.is_empty() {
119                    errors.push(ValidationError {
120                        path: format!("{}.columns", path),
121                        message: "Table must have at least one column".to_string(),
122                    });
123                }
124            }
125            Component::Chart(c) => {
126                if c.data.is_empty() {
127                    errors.push(ValidationError {
128                        path: format!("{}.data", path),
129                        message: "Chart must have data".to_string(),
130                    });
131                }
132                if c.y_keys.is_empty() {
133                    errors.push(ValidationError {
134                        path: format!("{}.y_keys", path),
135                        message: "Chart must have at least one y_key".to_string(),
136                    });
137                }
138            }
139            Component::Card(c) => {
140                for (i, child) in c.content.iter().enumerate() {
141                    errors.extend(child.validate(&format!("{}.content[{}]", path, i)));
142                }
143                if let Some(footer) = &c.footer {
144                    for (i, child) in footer.iter().enumerate() {
145                        errors.extend(child.validate(&format!("{}.footer[{}]", path, i)));
146                    }
147                }
148            }
149            Component::Modal(m) => {
150                for (i, child) in m.content.iter().enumerate() {
151                    errors.extend(child.validate(&format!("{}.content[{}]", path, i)));
152                }
153            }
154            Component::Stack(s) => {
155                for (i, child) in s.children.iter().enumerate() {
156                    errors.extend(child.validate(&format!("{}.children[{}]", path, i)));
157                }
158            }
159            Component::Grid(g) => {
160                for (i, child) in g.children.iter().enumerate() {
161                    errors.extend(child.validate(&format!("{}.children[{}]", path, i)));
162                }
163            }
164            Component::Tabs(t) => {
165                if t.tabs.is_empty() {
166                    errors.push(ValidationError {
167                        path: format!("{}.tabs", path),
168                        message: "Tabs must have at least one tab".to_string(),
169                    });
170                }
171            }
172            // Other components with minimal validation
173            _ => {}
174        }
175
176        errors
177    }
178}
179
180/// Validate a UiResponse and return Result
181pub fn validate_ui_response(ui: &UiResponse) -> Result<(), Vec<ValidationError>> {
182    let errors = ui.validate("UiResponse");
183    if errors.is_empty() {
184        Ok(())
185    } else {
186        Err(errors)
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn test_empty_response_fails() {
196        let ui = UiResponse::new(vec![]);
197        let result = validate_ui_response(&ui);
198        assert!(result.is_err());
199    }
200
201    #[test]
202    fn test_valid_text_passes() {
203        let ui = UiResponse::new(vec![Component::Text(Text {
204            id: None,
205            content: "Hello".to_string(),
206            variant: TextVariant::Body,
207        })]);
208        let result = validate_ui_response(&ui);
209        assert!(result.is_ok());
210    }
211
212    #[test]
213    fn test_empty_button_label_fails() {
214        let ui = UiResponse::new(vec![Component::Button(Button {
215            id: None,
216            label: "".to_string(),
217            action_id: "click".to_string(),
218            variant: ButtonVariant::Primary,
219            disabled: false,
220            icon: None,
221        })]);
222        let result = validate_ui_response(&ui);
223        assert!(result.is_err());
224    }
225}