use regex::Regex;
use serde_json::Value;
use std::collections::BTreeMap;
use crate::answers::{ValidationError, ValidationResult};
use crate::computed::{apply_computed_answers, build_expression_context};
use crate::spec::form::FormSpec;
use crate::spec::question::{QuestionSpec, QuestionType};
use crate::visibility::{VisibilityMode, resolve_visibility};
pub fn validate(spec: &FormSpec, answers: &Value) -> ValidationResult {
let computed_answers = apply_computed_answers(spec, answers);
let visibility = resolve_visibility(spec, &computed_answers, VisibilityMode::Visible);
let answers_map = computed_answers.as_object().cloned().unwrap_or_default();
let mut errors = Vec::new();
let mut missing_required = Vec::new();
for question in &spec.questions {
if !visibility.get(&question.id).copied().unwrap_or(true) {
continue;
}
match answers_map.get(&question.id) {
None => {
if question.required {
missing_required.push(question.id.clone());
}
}
Some(value) => {
if let Some(error) = validate_value(question, value) {
errors.push(error);
}
}
}
}
let all_ids: std::collections::BTreeSet<_> = spec
.questions
.iter()
.map(|question| question.id.clone())
.collect();
let unknown_fields: Vec<String> = answers_map
.keys()
.filter(|key| !all_ids.contains(*key))
.cloned()
.collect();
let ctx = build_expression_context(&computed_answers);
for validation in &spec.validations {
if let Some(true) = validation.condition.evaluate_bool(&ctx) {
let question_id = validation
.fields
.first()
.cloned()
.or_else(|| validation.id.clone());
let path = validation.fields.first().map(|field| format!("/{}", field));
errors.push(ValidationError {
question_id,
path,
message: validation.message.clone(),
code: validation.code.clone(),
params: BTreeMap::new(),
});
}
}
ValidationResult {
valid: errors.is_empty() && missing_required.is_empty() && unknown_fields.is_empty(),
errors,
missing_required,
unknown_fields,
}
}
fn validate_value(question: &QuestionSpec, value: &Value) -> Option<ValidationError> {
if !matches_type(question, value) {
return Some(ValidationError {
question_id: Some(question.id.clone()),
path: Some(format!("/{}", question.id)),
message: "qa_spec.type_mismatch".into(),
code: Some("type_mismatch".into()),
params: BTreeMap::new(),
});
}
if matches!(question.kind, QuestionType::List)
&& let Some(error) = validate_list(question, value)
{
return Some(error);
}
if let Some(constraint) = &question.constraint
&& let Some(error) = enforce_constraint(question, value, constraint)
{
return Some(error);
}
if matches!(question.kind, QuestionType::Enum)
&& let Some(choices) = &question.choices
&& let Some(text) = value.as_str()
&& !choices.contains(&text.to_string())
{
return Some(ValidationError {
question_id: Some(question.id.clone()),
path: Some(format!("/{}", question.id)),
message: "qa_spec.enum_mismatch".into(),
code: Some("enum_mismatch".into()),
params: BTreeMap::new(),
});
}
None
}
fn matches_type(question: &QuestionSpec, value: &Value) -> bool {
match question.kind {
QuestionType::String | QuestionType::Enum => value.is_string(),
QuestionType::Boolean => value.is_boolean(),
QuestionType::Integer => value.is_i64(),
QuestionType::Number => value.is_number(),
QuestionType::List => value.is_array(),
}
}
fn validate_list(question: &QuestionSpec, value: &Value) -> Option<ValidationError> {
let list = match &question.list {
Some(value) => value,
None => {
return Some(base_error(
question,
"qa_spec.missing_list_definition",
"missing_list_definition",
));
}
};
let items = match value.as_array() {
Some(items) => items,
None => {
return Some(list_not_array_error(question));
}
};
if let Some(min_items) = list.min_items
&& items.len() < min_items
{
return Some(list_count_error(
question,
min_items,
items.len(),
"qa_spec.min_items",
"min_items",
));
}
if let Some(max_items) = list.max_items
&& items.len() > max_items
{
return Some(list_count_error(
question,
max_items,
items.len(),
"qa_spec.max_items",
"max_items",
));
}
for (idx, entry) in items.iter().enumerate() {
let entry_map = match entry.as_object() {
Some(map) => map,
None => {
return Some(list_entry_type_error(question, idx));
}
};
for field in &list.fields {
match entry_map.get(&field.id) {
None => {
if field.required {
return Some(list_field_missing_error(question, idx, &field.id));
}
}
Some(field_value) => {
if let Some(error) = validate_value(field, field_value) {
return Some(apply_list_context(question, idx, field, error));
}
}
}
}
}
None
}
fn apply_list_context(
question: &QuestionSpec,
idx: usize,
field: &QuestionSpec,
mut error: ValidationError,
) -> ValidationError {
error.question_id = Some(format!("{}[{}].{}", question.id, idx, field.id));
error.path = Some(format!("/{}/{}/{}", question.id, idx, field.id));
error
}
fn list_count_error(
question: &QuestionSpec,
threshold: usize,
actual: usize,
message_key: &str,
code: &str,
) -> ValidationError {
let mut params = BTreeMap::new();
params.insert("expected".into(), threshold.to_string());
params.insert("actual".into(), actual.to_string());
ValidationError {
question_id: Some(question.id.clone()),
path: Some(format!("/{}", question.id)),
message: message_key.into(),
code: Some(code.into()),
params,
}
}
fn list_entry_type_error(question: &QuestionSpec, idx: usize) -> ValidationError {
ValidationError {
question_id: Some(question.id.clone()),
path: Some(format!("/{}/{}", question.id, idx)),
message: "qa_spec.entry_type".into(),
code: Some("entry_type".into()),
params: BTreeMap::new(),
}
}
fn list_not_array_error(question: &QuestionSpec) -> ValidationError {
ValidationError {
question_id: Some(question.id.clone()),
path: Some(format!("/{}", question.id)),
message: "qa_spec.list_type".into(),
code: Some("list_type".into()),
params: BTreeMap::new(),
}
}
fn list_field_missing_error(
question: &QuestionSpec,
idx: usize,
field_id: &str,
) -> ValidationError {
let mut params = BTreeMap::new();
params.insert("field".into(), field_id.to_string());
ValidationError {
question_id: Some(format!("{}[{}].{}", question.id, idx, field_id)),
path: Some(format!("/{}/{}/{}", question.id, idx, field_id)),
message: "qa_spec.missing_field".into(),
code: Some("missing_field".into()),
params,
}
}
fn enforce_constraint(
question: &QuestionSpec,
value: &Value,
constraint: &crate::spec::question::Constraint,
) -> Option<ValidationError> {
if let Some(pattern) = &constraint.pattern
&& let Some(text) = value.as_str()
&& let Ok(regex) = Regex::new(pattern)
&& !regex.is_match(text)
{
return Some(base_error(
question,
"qa_spec.pattern_mismatch",
"pattern_mismatch",
));
}
if let Some(min_len) = constraint.min_len
&& let Some(text) = value.as_str()
&& text.len() < min_len
{
return Some(base_error(question, "qa_spec.min_length", "min_length"));
}
if let Some(max_len) = constraint.max_len
&& let Some(text) = value.as_str()
&& text.len() > max_len
{
return Some(base_error(question, "qa_spec.max_length", "max_length"));
}
if let Some(min) = constraint.min
&& let Some(value) = value.as_f64()
&& value < min
{
return Some(base_error(question, "qa_spec.min", "min"));
}
if let Some(max) = constraint.max
&& let Some(value) = value.as_f64()
&& value > max
{
return Some(base_error(question, "qa_spec.max", "max"));
}
None
}
fn base_error(question: &QuestionSpec, message: &str, code: &str) -> ValidationError {
ValidationError {
question_id: Some(question.id.clone()),
path: Some(format!("/{}", question.id)),
message: message.into(),
code: Some(code.into()),
params: BTreeMap::new(),
}
}