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