Skip to main content

qa_spec/
validate.rs

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}