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}