1use regex::Regex;
2use serde_json::Value;
3
4use crate::answers::{ValidationError, ValidationResult};
5use crate::computed::{apply_computed_answers, build_expression_context};
6use crate::spec::form::FormSpec;
7use crate::spec::question::{QuestionSpec, QuestionType};
8use crate::visibility::{VisibilityMode, resolve_visibility};
9
10pub fn validate(spec: &FormSpec, answers: &Value) -> ValidationResult {
11 let computed_answers = apply_computed_answers(spec, answers);
12 let visibility = resolve_visibility(spec, &computed_answers, VisibilityMode::Visible);
13 let answers_map = computed_answers.as_object().cloned().unwrap_or_default();
14
15 let mut errors = Vec::new();
16 let mut missing_required = Vec::new();
17
18 for question in &spec.questions {
19 if !visibility.get(&question.id).copied().unwrap_or(true) {
20 continue;
21 }
22
23 match answers_map.get(&question.id) {
24 None => {
25 if question.required {
26 missing_required.push(question.id.clone());
27 }
28 }
29 Some(value) => {
30 if let Some(error) = validate_value(question, value) {
31 errors.push(error);
32 }
33 }
34 }
35 }
36
37 let all_ids: std::collections::BTreeSet<_> = spec
38 .questions
39 .iter()
40 .map(|question| question.id.clone())
41 .collect();
42 let unknown_fields: Vec<String> = answers_map
43 .keys()
44 .filter(|key| !all_ids.contains(*key))
45 .cloned()
46 .collect();
47
48 let ctx = build_expression_context(&computed_answers);
49 for validation in &spec.validations {
50 if let Some(true) = validation.condition.evaluate_bool(&ctx) {
51 let question_id = validation
52 .fields
53 .first()
54 .cloned()
55 .or_else(|| validation.id.clone());
56 let path = validation.fields.first().map(|field| format!("/{}", field));
57 errors.push(ValidationError {
58 question_id,
59 path,
60 message: validation.message.clone(),
61 code: validation.code.clone(),
62 });
63 }
64 }
65
66 ValidationResult {
67 valid: errors.is_empty() && missing_required.is_empty() && unknown_fields.is_empty(),
68 errors,
69 missing_required,
70 unknown_fields,
71 }
72}
73
74fn validate_value(question: &QuestionSpec, value: &Value) -> Option<ValidationError> {
75 if !matches_type(question, value) {
76 return Some(ValidationError {
77 question_id: Some(question.id.clone()),
78 path: Some(format!("/{}", question.id)),
79 message: "type mismatch".into(),
80 code: Some("type_mismatch".into()),
81 });
82 }
83
84 if matches!(question.kind, QuestionType::List)
85 && let Some(error) = validate_list(question, value)
86 {
87 return Some(error);
88 }
89
90 if let Some(constraint) = &question.constraint
91 && let Some(error) = enforce_constraint(question, value, constraint)
92 {
93 return Some(error);
94 }
95
96 if matches!(question.kind, QuestionType::Enum)
97 && let Some(choices) = &question.choices
98 && let Some(text) = value.as_str()
99 && !choices.contains(&text.to_string())
100 {
101 return Some(ValidationError {
102 question_id: Some(question.id.clone()),
103 path: Some(format!("/{}", question.id)),
104 message: "invalid enum option".into(),
105 code: Some("enum_mismatch".into()),
106 });
107 }
108
109 None
110}
111
112fn matches_type(question: &QuestionSpec, value: &Value) -> bool {
113 match question.kind {
114 QuestionType::String | QuestionType::Enum => value.is_string(),
115 QuestionType::Boolean => value.is_boolean(),
116 QuestionType::Integer => value.is_i64(),
117 QuestionType::Number => value.is_number(),
118 QuestionType::List => value.is_array(),
119 }
120}
121
122fn validate_list(question: &QuestionSpec, value: &Value) -> Option<ValidationError> {
123 let list = match &question.list {
124 Some(value) => value,
125 None => {
126 return Some(base_error(
127 question,
128 "list fields are not defined",
129 "missing_list_definition",
130 ));
131 }
132 };
133
134 let items = match value.as_array() {
135 Some(items) => items,
136 None => {
137 return Some(list_not_array_error(question));
138 }
139 };
140 if let Some(min_items) = list.min_items
141 && items.len() < min_items
142 {
143 return Some(list_count_error(
144 question,
145 min_items,
146 items.len(),
147 "not enough list entries",
148 "min_items",
149 ));
150 }
151
152 if let Some(max_items) = list.max_items
153 && items.len() > max_items
154 {
155 return Some(list_count_error(
156 question,
157 max_items,
158 items.len(),
159 "too many list entries",
160 "max_items",
161 ));
162 }
163
164 for (idx, entry) in items.iter().enumerate() {
165 let entry_map = match entry.as_object() {
166 Some(map) => map,
167 None => {
168 return Some(list_entry_type_error(question, idx));
169 }
170 };
171
172 for field in &list.fields {
173 match entry_map.get(&field.id) {
174 None => {
175 if field.required {
176 return Some(list_field_missing_error(question, idx, &field.id));
177 }
178 }
179 Some(field_value) => {
180 if let Some(error) = validate_value(field, field_value) {
181 return Some(apply_list_context(question, idx, field, error));
182 }
183 }
184 }
185 }
186 }
187
188 None
189}
190
191fn apply_list_context(
192 question: &QuestionSpec,
193 idx: usize,
194 field: &QuestionSpec,
195 mut error: ValidationError,
196) -> ValidationError {
197 error.question_id = Some(format!("{}[{}].{}", question.id, idx, field.id));
198 error.path = Some(format!("/{}/{}/{}", question.id, idx, field.id));
199 error
200}
201
202fn list_count_error(
203 question: &QuestionSpec,
204 threshold: usize,
205 actual: usize,
206 message: &str,
207 code: &str,
208) -> ValidationError {
209 ValidationError {
210 question_id: Some(question.id.clone()),
211 path: Some(format!("/{}", question.id)),
212 message: format!("{} (expected {}, got {})", message, threshold, actual),
213 code: Some(code.into()),
214 }
215}
216
217fn list_entry_type_error(question: &QuestionSpec, idx: usize) -> ValidationError {
218 ValidationError {
219 question_id: Some(question.id.clone()),
220 path: Some(format!("/{}/{}", question.id, idx)),
221 message: "list entry must be an object".into(),
222 code: Some("entry_type".into()),
223 }
224}
225
226fn list_not_array_error(question: &QuestionSpec) -> ValidationError {
227 ValidationError {
228 question_id: Some(question.id.clone()),
229 path: Some(format!("/{}", question.id)),
230 message: "list value must be an array".into(),
231 code: Some("list_type".into()),
232 }
233}
234
235fn list_field_missing_error(
236 question: &QuestionSpec,
237 idx: usize,
238 field_id: &str,
239) -> ValidationError {
240 ValidationError {
241 question_id: Some(format!("{}[{}].{}", question.id, idx, field_id)),
242 path: Some(format!("/{}/{}/{}", question.id, idx, field_id)),
243 message: format!("field '{}' is required", field_id),
244 code: Some("missing_field".into()),
245 }
246}
247
248fn enforce_constraint(
249 question: &QuestionSpec,
250 value: &Value,
251 constraint: &crate::spec::question::Constraint,
252) -> Option<ValidationError> {
253 if let Some(pattern) = &constraint.pattern
254 && let Some(text) = value.as_str()
255 && let Ok(regex) = Regex::new(pattern)
256 && !regex.is_match(text)
257 {
258 return Some(base_error(
259 question,
260 "value does not match pattern",
261 "pattern_mismatch",
262 ));
263 }
264
265 if let Some(min_len) = constraint.min_len
266 && let Some(text) = value.as_str()
267 && text.len() < min_len
268 {
269 return Some(base_error(
270 question,
271 "string shorter than min length",
272 "min_length",
273 ));
274 }
275
276 if let Some(max_len) = constraint.max_len
277 && let Some(text) = value.as_str()
278 && text.len() > max_len
279 {
280 return Some(base_error(
281 question,
282 "string longer than max length",
283 "max_length",
284 ));
285 }
286
287 if let Some(min) = constraint.min
288 && let Some(value) = value.as_f64()
289 && value < min
290 {
291 return Some(base_error(question, "value below minimum", "min"));
292 }
293
294 if let Some(max) = constraint.max
295 && let Some(value) = value.as_f64()
296 && value > max
297 {
298 return Some(base_error(question, "value above maximum", "max"));
299 }
300
301 None
302}
303
304fn base_error(question: &QuestionSpec, message: &str, code: &str) -> ValidationError {
305 ValidationError {
306 question_id: Some(question.id.clone()),
307 path: Some(format!("/{}", question.id)),
308 message: message.into(),
309 code: Some(code.into()),
310 }
311}