Skip to main content

qa_spec/
validate.rs

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}