Skip to main content

standout_input/questionnaire/
decode.rs

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}