Skip to main content

qa_spec/
validate.rs

1use regex::Regex;
2use serde_json::Value;
3
4use crate::answers::{ValidationError, ValidationResult};
5use crate::spec::form::FormSpec;
6use crate::spec::question::{QuestionSpec, QuestionType};
7use crate::visibility::{VisibilityMode, resolve_visibility};
8
9pub fn validate(spec: &FormSpec, answers: &Value) -> ValidationResult {
10    let visibility = resolve_visibility(spec, answers, VisibilityMode::Visible);
11    let answers_map = answers.as_object().cloned().unwrap_or_default();
12
13    let mut errors = Vec::new();
14    let mut missing_required = Vec::new();
15
16    for question in &spec.questions {
17        if !visibility.get(&question.id).copied().unwrap_or(true) {
18            continue;
19        }
20
21        match answers_map.get(&question.id) {
22            None => {
23                if question.required {
24                    missing_required.push(question.id.clone());
25                }
26            }
27            Some(value) => {
28                if let Some(error) = validate_value(question, value) {
29                    errors.push(error);
30                }
31            }
32        }
33    }
34
35    let all_ids: std::collections::BTreeSet<_> = spec
36        .questions
37        .iter()
38        .map(|question| question.id.clone())
39        .collect();
40    let unknown_fields: Vec<String> = answers_map
41        .keys()
42        .filter(|key| !all_ids.contains(*key))
43        .cloned()
44        .collect();
45
46    ValidationResult {
47        valid: errors.is_empty() && missing_required.is_empty() && unknown_fields.is_empty(),
48        errors,
49        missing_required,
50        unknown_fields,
51    }
52}
53
54fn validate_value(question: &QuestionSpec, value: &Value) -> Option<ValidationError> {
55    if !matches_type(question, value) {
56        return Some(ValidationError {
57            question_id: Some(question.id.clone()),
58            path: Some(format!("/{}", question.id)),
59            message: "type mismatch".into(),
60            code: Some("type_mismatch".into()),
61        });
62    }
63
64    if let Some(constraint) = &question.constraint
65        && let Some(error) = enforce_constraint(question, value, constraint)
66    {
67        return Some(error);
68    }
69
70    if matches!(question.kind, QuestionType::Enum)
71        && let Some(choices) = &question.choices
72        && let Some(text) = value.as_str()
73        && !choices.contains(&text.to_string())
74    {
75        return Some(ValidationError {
76            question_id: Some(question.id.clone()),
77            path: Some(format!("/{}", question.id)),
78            message: "invalid enum option".into(),
79            code: Some("enum_mismatch".into()),
80        });
81    }
82
83    None
84}
85
86fn matches_type(question: &QuestionSpec, value: &Value) -> bool {
87    match question.kind {
88        QuestionType::String | QuestionType::Enum => value.is_string(),
89        QuestionType::Boolean => value.is_boolean(),
90        QuestionType::Integer => value.is_i64(),
91        QuestionType::Number => value.is_number(),
92    }
93}
94
95fn enforce_constraint(
96    question: &QuestionSpec,
97    value: &Value,
98    constraint: &crate::spec::question::Constraint,
99) -> Option<ValidationError> {
100    if let Some(pattern) = &constraint.pattern
101        && let Some(text) = value.as_str()
102        && let Ok(regex) = Regex::new(pattern)
103        && !regex.is_match(text)
104    {
105        return Some(base_error(
106            question,
107            "value does not match pattern",
108            "pattern_mismatch",
109        ));
110    }
111
112    if let Some(min_len) = constraint.min_len
113        && let Some(text) = value.as_str()
114        && text.len() < min_len
115    {
116        return Some(base_error(
117            question,
118            "string shorter than min length",
119            "min_length",
120        ));
121    }
122
123    if let Some(max_len) = constraint.max_len
124        && let Some(text) = value.as_str()
125        && text.len() > max_len
126    {
127        return Some(base_error(
128            question,
129            "string longer than max length",
130            "max_length",
131        ));
132    }
133
134    if let Some(min) = constraint.min
135        && let Some(value) = value.as_f64()
136        && value < min
137    {
138        return Some(base_error(question, "value below minimum", "min"));
139    }
140
141    if let Some(max) = constraint.max
142        && let Some(value) = value.as_f64()
143        && value > max
144    {
145        return Some(base_error(question, "value above maximum", "max"));
146    }
147
148    None
149}
150
151fn base_error(question: &QuestionSpec, message: &str, code: &str) -> ValidationError {
152    ValidationError {
153        question_id: Some(question.id.clone()),
154        path: Some(format!("/{}", question.id)),
155        message: message.into(),
156        code: Some(code.into()),
157    }
158}