1use std::collections::BTreeMap;
2
3use super::definition::{
4 child_segment, path_join, Constraint, Item, Questionnaire, ScalarField, ScalarKind,
5};
6use super::parse::RawAnswers;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum AnswerValue {
10 Text(String),
11 Bool(bool),
12}
13
14impl AnswerValue {
15 pub fn as_text(&self) -> Option<&str> {
16 match self {
17 AnswerValue::Text(s) => Some(s),
18 AnswerValue::Bool(_) => None,
19 }
20 }
21
22 pub fn as_bool(&self) -> Option<bool> {
23 match self {
24 AnswerValue::Bool(b) => Some(*b),
25 AnswerValue::Text(_) => None,
26 }
27 }
28
29 pub(crate) fn canonical(&self) -> String {
30 match self {
31 AnswerValue::Text(s) => s.clone(),
32 AnswerValue::Bool(b) => if *b { "true" } else { "false" }.to_string(),
33 }
34 }
35}
36
37#[derive(Debug, Clone, Default, PartialEq, Eq)]
38pub struct Answers {
39 values: BTreeMap<String, AnswerValue>,
40 occurrences: BTreeMap<String, usize>,
41}
42
43impl Answers {
44 pub fn get(&self, path: &str) -> Option<&AnswerValue> {
45 self.values.get(path)
46 }
47
48 pub fn get_text(&self, path: &str) -> Option<&str> {
49 self.get(path).and_then(AnswerValue::as_text)
50 }
51
52 pub fn get_bool(&self, path: &str) -> Option<bool> {
53 self.get(path).and_then(AnswerValue::as_bool)
54 }
55
56 pub fn occurrence_count(&self, group_path: &str) -> usize {
57 self.occurrences.get(group_path).copied().unwrap_or(0)
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct FormError {
63 pub fields: Vec<String>,
64 pub message: String,
65}
66
67impl FormError {
68 pub fn new(
69 fields: impl IntoIterator<Item = impl Into<String>>,
70 message: impl Into<String>,
71 ) -> Self {
72 Self {
73 fields: fields.into_iter().map(Into::into).collect(),
74 message: message.into(),
75 }
76 }
77}
78
79#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
80pub enum ValidationDiagnostic {
81 #[error("[{id}]: {message}")]
82 Field { id: String, message: String },
83
84 #[error("{}", form_display(.fields, .message))]
85 Form {
86 fields: Vec<String>,
87 message: String,
88 },
89}
90
91impl ValidationDiagnostic {
92 pub(crate) fn field(id: impl Into<String>, message: impl Into<String>) -> Self {
93 Self::Field {
94 id: id.into(),
95 message: message.into(),
96 }
97 }
98}
99
100fn form_display(fields: &[String], message: &str) -> String {
101 if fields.is_empty() {
102 message.to_string()
103 } else {
104 format!("{message} (fields: {})", fields.join(", "))
105 }
106}
107
108pub(crate) fn parse_bool(text: &str) -> Option<bool> {
109 match text.trim().to_ascii_lowercase().as_str() {
110 "true" | "yes" | "y" => Some(true),
111 "false" | "no" | "n" => Some(false),
112 _ => None,
113 }
114}
115
116pub(crate) fn check_field_text(
117 field: &ScalarField,
118 path: &str,
119 text: &str,
120) -> Result<AnswerValue, ValidationDiagnostic> {
121 let value = match field.kind() {
122 ScalarKind::Text => AnswerValue::Text(text.to_string()),
123 ScalarKind::String | ScalarKind::Path => {
124 if text.contains('\n') {
125 return Err(ValidationDiagnostic::field(
126 path,
127 format!("a {} answer must be a single line", field.kind().name()),
128 ));
129 }
130 AnswerValue::Text(text.to_string())
131 }
132 ScalarKind::Bool => match parse_bool(text) {
133 Some(b) => AnswerValue::Bool(b),
134 None => {
135 return Err(ValidationDiagnostic::field(
136 path,
137 "expected a yes/no answer (true, false, yes, no, y, or n)",
138 ))
139 }
140 },
141 };
142 if let Some(Constraint::OneOf(choices)) = field.constraint() {
143 let matches = value
144 .as_text()
145 .is_some_and(|t| choices.iter().any(|c| c == t));
146 if !matches {
147 return Err(ValidationDiagnostic::field(
148 path,
149 format!("the answer must be one of: {}.", choices.join(", ")),
150 ));
151 }
152 }
153 if let Some(validator) = field.validator() {
154 if let Err(message) = validator.check(&value) {
155 return Err(ValidationDiagnostic::field(path, message));
156 }
157 }
158 Ok(value)
159}
160
161pub(crate) fn decode_field(
162 field: &ScalarField,
163 path: &str,
164 raw: Option<&str>,
165 computed: Option<&str>,
166) -> Result<Option<AnswerValue>, ValidationDiagnostic> {
167 let submitted = raw.map(str::trim).filter(|t| !t.is_empty());
168 let effective = submitted.or(field.default()).or(computed);
169 match effective {
170 Some(text) => check_field_text(field, path, text).map(Some),
171 None if field.is_optional() => Ok(None),
172 None => Err(ValidationDiagnostic::field(
173 path,
174 "this question requires an answer.",
175 )),
176 }
177}
178
179pub(crate) enum FieldOutcome {
180 Answered(AnswerValue),
181 Omitted,
182 Inactive,
183 Errored,
184}
185
186#[derive(Debug, Clone)]
187pub(crate) struct ScopeCtx {
188 pub(crate) group_id: Option<String>,
189 pub(crate) def_prefix: String,
190 pub(crate) path_prefix: String,
191}
192
193impl ScopeCtx {
194 pub(crate) fn root() -> Self {
195 Self {
196 group_id: None,
197 def_prefix: String::new(),
198 path_prefix: String::new(),
199 }
200 }
201
202 pub(crate) fn child_path(&self, id: &str) -> String {
203 path_join(&self.path_prefix, child_segment(&self.def_prefix, id))
204 }
205}
206
207pub struct EarlierAnswers<'a> {
208 questionnaire: &'a Questionnaire,
209 chain: &'a [ScopeCtx],
210 outcomes: &'a BTreeMap<String, FieldOutcome>,
211}
212
213impl<'a> EarlierAnswers<'a> {
214 pub(crate) fn new(
215 questionnaire: &'a Questionnaire,
216 chain: &'a [ScopeCtx],
217 outcomes: &'a BTreeMap<String, FieldOutcome>,
218 ) -> Self {
219 Self {
220 questionnaire,
221 chain,
222 outcomes,
223 }
224 }
225
226 pub fn get(&self, field_id: &str) -> Option<&AnswerValue> {
227 let meta = self.questionnaire.node_meta(field_id)?;
228 if meta.group {
229 return None;
230 }
231 let scope = self
232 .chain
233 .iter()
234 .rev()
235 .find(|scope| scope.group_id.as_deref() == meta.parent.as_deref())?;
236 match self.outcomes.get(&scope.child_path(field_id)) {
237 Some(FieldOutcome::Answered(value)) => Some(value),
238 _ => None,
239 }
240 }
241
242 pub fn get_text(&self, field_id: &str) -> Option<&str> {
243 self.get(field_id).and_then(AnswerValue::as_text)
244 }
245
246 pub fn get_bool(&self, field_id: &str) -> Option<bool> {
247 self.get(field_id).and_then(AnswerValue::as_bool)
248 }
249}
250
251pub(crate) fn controller_path(
252 questionnaire: &Questionnaire,
253 chain: &[ScopeCtx],
254 controller: &str,
255) -> String {
256 let parent = questionnaire
257 .node_meta(controller)
258 .expect("conditions are validated at construction")
259 .parent
260 .as_deref();
261 let scope = chain
262 .iter()
263 .rev()
264 .find(|scope| scope.group_id.as_deref() == parent)
265 .expect("the controller's scope encloses the dependent's");
266 scope.child_path(controller)
267}
268
269pub(crate) fn is_active(
270 questionnaire: &Questionnaire,
271 field: &ScalarField,
272 chain: &[ScopeCtx],
273 outcomes: &BTreeMap<String, FieldOutcome>,
274) -> Option<bool> {
275 let Some(condition) = field.condition() else {
276 return Some(true);
277 };
278 let path = controller_path(questionnaire, chain, condition.controller());
279 match outcomes.get(&path) {
280 Some(FieldOutcome::Answered(value)) => Some(value.canonical() == condition.expected()),
281 Some(FieldOutcome::Omitted) | Some(FieldOutcome::Inactive) => Some(false),
282 Some(FieldOutcome::Errored) | None => None,
283 }
284}
285
286impl Questionnaire {
287 pub fn decode_answers(&self, raw: &RawAnswers) -> Result<Answers, Vec<ValidationDiagnostic>> {
288 let mut outcomes: BTreeMap<String, FieldOutcome> = BTreeMap::new();
289 let mut occurrences: BTreeMap<String, usize> = BTreeMap::new();
290 let mut diagnostics = Vec::new();
291
292 self.decode_items(
293 self.items(),
294 &mut vec![ScopeCtx::root()],
295 raw,
296 &mut outcomes,
297 &mut occurrences,
298 &mut diagnostics,
299 );
300
301 if !diagnostics.is_empty() {
302 return Err(diagnostics);
303 }
304 let values = outcomes
305 .into_iter()
306 .filter_map(|(path, outcome)| match outcome {
307 FieldOutcome::Answered(value) => Some((path, value)),
308 _ => None,
309 })
310 .collect();
311 Ok(Answers {
312 values,
313 occurrences,
314 })
315 }
316
317 fn decode_items(
318 &self,
319 items: &[Item],
320 chain: &mut Vec<ScopeCtx>,
321 raw: &RawAnswers,
322 outcomes: &mut BTreeMap<String, FieldOutcome>,
323 occurrences: &mut BTreeMap<String, usize>,
324 diagnostics: &mut Vec<ValidationDiagnostic>,
325 ) {
326 for item in items {
327 match item {
328 Item::Field(field) => {
329 let path = chain
330 .last()
331 .expect("chain starts rooted")
332 .child_path(field.id());
333 let raw_value = raw.get(&path);
334 let outcome = match is_active(self, field, chain, outcomes) {
335 None => FieldOutcome::Errored,
336 Some(false) => {
337 let blank = raw_value.is_none_or(|t| t.trim().is_empty());
338 let untouched_default =
339 field.default().is_some() && raw_value == field.default();
340 if blank || untouched_default {
341 FieldOutcome::Inactive
342 } else {
343 let condition =
344 field.condition().expect("inactive implies condition");
345 diagnostics.push(ValidationDiagnostic::field(
346 path.clone(),
347 format!(
348 "this question does not apply (it is asked only when {} is {}); remove its answer or change the controlling answer.",
349 condition.controller(),
350 condition.expected()
351 ),
352 ));
353 FieldOutcome::Errored
354 }
355 }
356 Some(true) => {
357 let computed = field.dynamic_default().map(|dynamic| {
358 dynamic.compute(&EarlierAnswers::new(self, chain, outcomes))
359 });
360 match decode_field(field, &path, raw_value, computed.as_deref()) {
361 Ok(Some(value)) => FieldOutcome::Answered(value),
362 Ok(None) => FieldOutcome::Omitted,
363 Err(diagnostic) => {
364 diagnostics.push(diagnostic);
365 FieldOutcome::Errored
366 }
367 }
368 }
369 };
370 outcomes.insert(path, outcome);
371 }
372 Item::Group(group) => {
373 let base = chain
374 .last()
375 .expect("chain starts rooted")
376 .child_path(group.id());
377 match group.repeat() {
378 None => {
379 chain.push(ScopeCtx {
380 group_id: Some(group.id().to_string()),
381 def_prefix: group.def_prefix(),
382 path_prefix: base,
383 });
384 self.decode_items(
385 group.children(),
386 chain,
387 raw,
388 outcomes,
389 occurrences,
390 diagnostics,
391 );
392 chain.pop();
393 }
394 Some(repeat) => {
395 let count = raw.occurrence_count(&base);
396 if count < repeat.min() {
397 diagnostics.push(ValidationDiagnostic::field(
398 base.clone(),
399 format!(
400 "{count} of at least {} required item(s) submitted. Copy a complete group block - its heading line and its questions - for each missing item.",
401 repeat.min()
402 ),
403 ));
404 }
405 if let Some(max) = repeat.max() {
406 if count > max {
407 diagnostics.push(ValidationDiagnostic::field(
408 base.clone(),
409 format!(
410 "{count} items submitted, but at most {max} are accepted. Remove the extra group block(s)."
411 ),
412 ));
413 }
414 }
415 if count > 0 {
416 occurrences.insert(base.clone(), count);
417 }
418 for index in 0..count {
419 chain.push(ScopeCtx {
420 group_id: Some(group.id().to_string()),
421 def_prefix: group.def_prefix(),
422 path_prefix: format!("{base}[{index}]"),
423 });
424 self.decode_items(
425 group.children(),
426 chain,
427 raw,
428 outcomes,
429 occurrences,
430 diagnostics,
431 );
432 chain.pop();
433 }
434 }
435 }
436 }
437 }
438 }
439 }
440
441 pub fn decode_answers_with<F>(
442 &self,
443 raw: &RawAnswers,
444 form: F,
445 ) -> Result<Answers, Vec<ValidationDiagnostic>>
446 where
447 F: FnOnce(&Answers) -> Vec<FormError>,
448 {
449 let answers = self.decode_answers(raw)?;
450 let form_errors = form(&answers);
451 if form_errors.is_empty() {
452 return Ok(answers);
453 }
454 Err(form_errors
455 .into_iter()
456 .map(|e| ValidationDiagnostic::Form {
457 fields: e.fields,
458 message: e.message,
459 })
460 .collect())
461 }
462}