Skip to main content

standout_input/questionnaire/
decode.rs

1//! Shared decoding and validation of raw answers.
2//!
3//! Every collection path — interactive prompts, named files, and explicit
4//! stdin — normalizes to [`RawAnswers`] and runs through the functions here,
5//! so equivalent raw text always decodes to the same value with the same
6//! diagnostics. There are no adapter-specific conversions or messages.
7//!
8//! Decoding one field applies, in order: blank resolution (a blank answer
9//! resolves to the declared default first — the static value, or a
10//! [`DynamicDefault`](super::DynamicDefault) computed from earlier decoded
11//! answers via [`EarlierAnswers`]; an optional blank without a default is
12//! an omission; a required blank without a default is a missing-value
13//! error), kind conversion, constraint checking, and the application's
14//! [`FieldValidator`](super::FieldValidator). Whole-document
15//! decoding ([`Questionnaire::decode_answers`]) additionally evaluates
16//! conditional applicability and accumulates every independent diagnostic
17//! instead of stopping at the first; the application's whole-form rules
18//! join the same accumulated list via
19//! [`Questionnaire::decode_answers_with`].
20//!
21//! Nested and repeated values run through the exact same pipeline as
22//! scalars: decoding walks the definition tree, visits every submitted
23//! occurrence of each repeatable group, and addresses each value by its
24//! *occurrence path* (`command.inputs[1].name`) — the stable definition ID
25//! with a zero-based index per enclosing repeatable-group occurrence.
26//! Occurrence counts outside a group's declared [`Repeat`](super::Repeat)
27//! bounds are reported here as structural diagnostics, alongside the value
28//! diagnostics of the occurrences that do exist.
29//!
30//! Diagnostics identify fields by stable occurrence path and never echo
31//! submitted values; see the [module documentation](crate::questionnaire)
32//! for why.
33
34use std::collections::BTreeMap;
35
36use super::definition::{
37    child_segment, path_join, Constraint, Item, Questionnaire, ScalarField, ScalarKind,
38};
39use super::parse::RawAnswers;
40
41/// A decoded, field-validated answer value.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum AnswerValue {
44    /// The value of a `String`, `Text`, or `Path` field.
45    Text(String),
46    /// The value of a `Bool` field.
47    Bool(bool),
48}
49
50impl AnswerValue {
51    /// The text content, for `String` / `Text` / `Path` fields.
52    pub fn as_text(&self) -> Option<&str> {
53        match self {
54            AnswerValue::Text(s) => Some(s),
55            AnswerValue::Bool(_) => None,
56        }
57    }
58
59    /// The boolean content, for `Bool` fields.
60    pub fn as_bool(&self) -> Option<bool> {
61        match self {
62            AnswerValue::Bool(b) => Some(*b),
63            AnswerValue::Text(_) => None,
64        }
65    }
66
67    /// Canonical string form, used to evaluate conditions: text verbatim,
68    /// bools as `true` / `false`.
69    pub(crate) fn canonical(&self) -> String {
70        match self {
71            AnswerValue::Text(s) => s.clone(),
72            AnswerValue::Bool(b) => if *b { "true" } else { "false" }.to_string(),
73        }
74    }
75}
76
77/// The decoded, validated answers for one questionnaire submission.
78///
79/// Contains one entry per *answered* occurrence path — the stable field ID,
80/// with a zero-based index per enclosing repeatable-group occurrence
81/// (`command.inputs[1].name`). Omitted optional fields and inactive
82/// conditional fields are absent. Repeatable-group occurrence counts are
83/// carried alongside ([`occurrence_count`](Self::occurrence_count)), so an
84/// application can iterate submitted items without guessing from key shapes.
85/// Conversion into application domain types starts from here.
86#[derive(Debug, Clone, Default, PartialEq, Eq)]
87pub struct Answers {
88    values: BTreeMap<String, AnswerValue>,
89    occurrences: BTreeMap<String, usize>,
90}
91
92impl Answers {
93    /// The decoded value at an occurrence path (for fields outside
94    /// repeatable groups: the stable field ID), if it was answered.
95    pub fn get(&self, path: &str) -> Option<&AnswerValue> {
96        self.values.get(path)
97    }
98
99    /// The text value for a `String` / `Text` / `Path` field, if answered.
100    pub fn get_text(&self, path: &str) -> Option<&str> {
101        self.get(path).and_then(AnswerValue::as_text)
102    }
103
104    /// The boolean value for a `Bool` field, if answered.
105    pub fn get_bool(&self, path: &str) -> Option<bool> {
106        self.get(path).and_then(AnswerValue::as_bool)
107    }
108
109    /// How many occurrences of a repeatable group were submitted, addressed
110    /// by the group's occurrence path base — `command.inputs` at the root,
111    /// `command.inputs[0].flags` for a group nested in another occurrence.
112    pub fn occurrence_count(&self, group_path: &str) -> usize {
113        self.occurrences.get(group_path).copied().unwrap_or(0)
114    }
115}
116
117/// One whole-form error returned by an application form validator.
118///
119/// Mapped into [`ValidationDiagnostic::Form`] so form-level findings
120/// accumulate in the same list as field-level ones. The message should
121/// describe the rule without echoing submitted values.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct FormError {
124    /// Stable IDs of the fields involved (may be empty for a global rule).
125    pub fields: Vec<String>,
126    /// User-facing description of the violated rule.
127    pub message: String,
128}
129
130impl FormError {
131    /// Create a form error over the given fields.
132    pub fn new(
133        fields: impl IntoIterator<Item = impl Into<String>>,
134        message: impl Into<String>,
135    ) -> Self {
136        Self {
137            fields: fields.into_iter().map(Into::into).collect(),
138            message: message.into(),
139        }
140    }
141}
142
143/// One problem found while decoding and validating raw answers.
144///
145/// Diagnostics identify fields by stable occurrence path (for fields outside
146/// repeatable groups: the stable field ID) and describe the violated rule;
147/// they never echo the submitted value, since answers may be sensitive.
148/// Independent diagnostics accumulate: a batch submission reports everything
149/// actionable in one pass.
150#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
151pub enum ValidationDiagnostic {
152    /// One field occurrence — or one repeatable group's occurrence count —
153    /// violates the definition: a missing required answer, a populated
154    /// inactive field, a failed kind conversion, a constraint violation, a
155    /// rejected validator, or an occurrence count outside the declared
156    /// bounds. `message` describes the violated rule without echoing the
157    /// submitted value.
158    #[error("[{id}]: {message}")]
159    Field {
160        /// The occurrence path of the violating field (the group's
161        /// occurrence path base for occurrence-bound violations).
162        id: String,
163        /// The violated rule, user-facing.
164        message: String,
165    },
166
167    /// An application whole-form rule was violated.
168    #[error("{}", form_display(.fields, .message))]
169    Form {
170        /// Stable IDs of the fields involved (may be empty).
171        fields: Vec<String>,
172        /// The rule's user-facing message.
173        message: String,
174    },
175}
176
177impl ValidationDiagnostic {
178    /// A field-level diagnostic at an occurrence path.
179    pub(crate) fn field(id: impl Into<String>, message: impl Into<String>) -> Self {
180        Self::Field {
181            id: id.into(),
182            message: message.into(),
183        }
184    }
185}
186
187/// Render a whole-form diagnostic: the rule's message, plus the involved
188/// stable field IDs when the rule names any.
189fn form_display(fields: &[String], message: &str) -> String {
190    if fields.is_empty() {
191        message.to_string()
192    } else {
193        format!("{message} (fields: {})", fields.join(", "))
194    }
195}
196
197/// Parse the shared boolean vocabulary: `true`/`false`/`yes`/`no`/`y`/`n`,
198/// case-insensitive. This is the single bool decoder for every collection
199/// path and for canonicalizing condition expected values.
200pub(crate) fn parse_bool(text: &str) -> Option<bool> {
201    match text.trim().to_ascii_lowercase().as_str() {
202        "true" | "yes" | "y" => Some(true),
203        "false" | "no" | "n" => Some(false),
204        _ => None,
205    }
206}
207
208/// Convert non-blank answer text through kind conversion, constraint
209/// checking, and the application validator. `path` is the occurrence path
210/// used in diagnostics (the field's own ID outside repeated groups).
211///
212/// Shared by every collection path and by definition-time default
213/// validation. Diagnostic reasons never include `text`.
214pub(crate) fn check_field_text(
215    field: &ScalarField,
216    path: &str,
217    text: &str,
218) -> Result<AnswerValue, ValidationDiagnostic> {
219    let value = match field.kind() {
220        ScalarKind::Text => AnswerValue::Text(text.to_string()),
221        ScalarKind::String | ScalarKind::Path => {
222            if text.contains('\n') {
223                return Err(ValidationDiagnostic::field(
224                    path,
225                    format!("a {} answer must be a single line", field.kind().name()),
226                ));
227            }
228            AnswerValue::Text(text.to_string())
229        }
230        ScalarKind::Bool => match parse_bool(text) {
231            Some(b) => AnswerValue::Bool(b),
232            None => {
233                return Err(ValidationDiagnostic::field(
234                    path,
235                    "expected a yes/no answer (true, false, yes, no, y, or n)",
236                ))
237            }
238        },
239    };
240    if let Some(Constraint::OneOf(choices)) = field.constraint() {
241        let matches = value
242            .as_text()
243            .is_some_and(|t| choices.iter().any(|c| c == t));
244        if !matches {
245            return Err(ValidationDiagnostic::field(
246                path,
247                format!("the answer must be one of: {}.", choices.join(", ")),
248            ));
249        }
250    }
251    if let Some(validator) = field.validator() {
252        if let Err(message) = validator.check(&value) {
253            return Err(ValidationDiagnostic::field(path, message));
254        }
255    }
256    Ok(value)
257}
258
259/// Decode one active field's raw answer, identified in diagnostics by its
260/// occurrence `path`.
261///
262/// `raw` is the trimmed answer text, or `None` when the field is absent from
263/// the submission. `computed` is the field's computed dynamic default, when
264/// it declares one (the caller evaluates the closure against its
265/// earlier-outcomes view; static and dynamic defaults are mutually
266/// exclusive by construction). Blank resolves through the declared —
267/// static or computed — default first; a blank without a default is an
268/// omission (`Ok(None)`) when optional and a
269/// missing-answer [`ValidationDiagnostic`] when required. A computed
270/// default runs through the same kind / constraint / validator pipeline as
271/// any answer.
272pub(crate) fn decode_field(
273    field: &ScalarField,
274    path: &str,
275    raw: Option<&str>,
276    computed: Option<&str>,
277) -> Result<Option<AnswerValue>, ValidationDiagnostic> {
278    let submitted = raw.map(str::trim).filter(|t| !t.is_empty());
279    let effective = submitted.or(field.default()).or(computed);
280    match effective {
281        Some(text) => check_field_text(field, path, text).map(Some),
282        None if field.is_optional() => Ok(None),
283        None => Err(ValidationDiagnostic::field(
284            path,
285            "this question requires an answer.",
286        )),
287    }
288}
289
290/// What happened to one field occurrence during a whole-document decode
291/// pass. Keyed by occurrence path.
292pub(crate) enum FieldOutcome {
293    /// Decoded and validated to a value.
294    Answered(AnswerValue),
295    /// Active and optional, left blank without a default.
296    Omitted,
297    /// Condition unsatisfied; the field was (correctly) not answered.
298    Inactive,
299    /// This field (or its controller chain) produced a diagnostic, so
300    /// dependents cannot be judged and are skipped without diagnostics.
301    Errored,
302}
303
304/// One open definition scope during a decode or collection walk: the root,
305/// or a group occurrence.
306#[derive(Debug, Clone)]
307pub(crate) struct ScopeCtx {
308    /// The scope's group ID (`None` at the questionnaire root).
309    pub(crate) group_id: Option<String>,
310    /// The definition-ID prefix children of this scope extend (`""` at the
311    /// root, `<group_id>.` inside a group).
312    pub(crate) def_prefix: String,
313    /// The occurrence path prefix of this scope (`""`, `command`, or
314    /// `command.inputs[1]`).
315    pub(crate) path_prefix: String,
316}
317
318impl ScopeCtx {
319    /// The root scope every walk starts from.
320    pub(crate) fn root() -> Self {
321        Self {
322            group_id: None,
323            def_prefix: String::new(),
324            path_prefix: String::new(),
325        }
326    }
327
328    /// The occurrence path of a direct child of this scope.
329    pub(crate) fn child_path(&self, id: &str) -> String {
330        path_join(&self.path_prefix, child_segment(&self.def_prefix, id))
331    }
332}
333
334/// A read-only view of the answers decoded *before* the current field in
335/// the same scope chain, handed to a
336/// [`DynamicDefault`](super::DynamicDefault) closure.
337///
338/// Lookups take a stable *definition ID* (`command.inputs.value_type`) and
339/// resolve it against the walk's open scope chain, exactly like a condition
340/// controller: a field inside a repeatable group resolves to the occurrence
341/// currently being collected. The view is deliberately forgiving where
342/// construction cannot check the closure's dependencies: an unknown,
343/// out-of-scope, later-declared (not yet walked), omitted, inactive, or
344/// errored field reads as `None` — depending on such a field is a contract
345/// violation, and the closure must still return a usable default.
346pub struct EarlierAnswers<'a> {
347    questionnaire: &'a Questionnaire,
348    chain: &'a [ScopeCtx],
349    outcomes: &'a BTreeMap<String, FieldOutcome>,
350}
351
352impl<'a> EarlierAnswers<'a> {
353    /// Build the view over the walk state at one field occurrence.
354    pub(crate) fn new(
355        questionnaire: &'a Questionnaire,
356        chain: &'a [ScopeCtx],
357        outcomes: &'a BTreeMap<String, FieldOutcome>,
358    ) -> Self {
359        Self {
360            questionnaire,
361            chain,
362            outcomes,
363        }
364    }
365
366    /// The decoded value of the earlier field with the given stable
367    /// definition ID, resolved in the current scope chain, or `None` when
368    /// the field has no decoded value (see the type-level contract).
369    pub fn get(&self, field_id: &str) -> Option<&AnswerValue> {
370        let meta = self.questionnaire.node_meta(field_id)?;
371        if meta.group {
372            return None;
373        }
374        let scope = self
375            .chain
376            .iter()
377            .rev()
378            .find(|scope| scope.group_id.as_deref() == meta.parent.as_deref())?;
379        match self.outcomes.get(&scope.child_path(field_id)) {
380            Some(FieldOutcome::Answered(value)) => Some(value),
381            _ => None,
382        }
383    }
384
385    /// The text value of an earlier `String` / `Text` / `Path` field, if
386    /// answered.
387    pub fn get_text(&self, field_id: &str) -> Option<&str> {
388        self.get(field_id).and_then(AnswerValue::as_text)
389    }
390
391    /// The boolean value of an earlier `Bool` field, if answered.
392    pub fn get_bool(&self, field_id: &str) -> Option<bool> {
393        self.get(field_id).and_then(AnswerValue::as_bool)
394    }
395}
396
397/// Resolve a condition controller's occurrence path against the current
398/// scope chain: the controller lives in whichever open scope declares it as
399/// a direct child (construction guarantees that scope is on the chain).
400pub(crate) fn controller_path(
401    questionnaire: &Questionnaire,
402    chain: &[ScopeCtx],
403    controller: &str,
404) -> String {
405    let parent = questionnaire
406        .node_meta(controller)
407        .expect("conditions are validated at construction")
408        .parent
409        .as_deref();
410    let scope = chain
411        .iter()
412        .rev()
413        .find(|scope| scope.group_id.as_deref() == parent)
414        .expect("the controller's scope encloses the dependent's");
415    scope.child_path(controller)
416}
417
418/// Evaluate a field's applicability from earlier outcomes, resolving its
419/// controller within the current scope chain (a controller inside a
420/// repeatable group controls per occurrence).
421///
422/// `Some(true)` / `Some(false)` when applicability is known; `None` when the
423/// controller (or its chain) errored, so the field cannot be judged.
424pub(crate) fn is_active(
425    questionnaire: &Questionnaire,
426    field: &ScalarField,
427    chain: &[ScopeCtx],
428    outcomes: &BTreeMap<String, FieldOutcome>,
429) -> Option<bool> {
430    let Some(condition) = field.condition() else {
431        return Some(true);
432    };
433    let path = controller_path(questionnaire, chain, condition.controller());
434    match outcomes.get(&path) {
435        Some(FieldOutcome::Answered(value)) => Some(value.canonical() == condition.expected()),
436        Some(FieldOutcome::Omitted) | Some(FieldOutcome::Inactive) => Some(false),
437        Some(FieldOutcome::Errored) | None => None,
438    }
439}
440
441impl Questionnaire {
442    /// Decode and validate a complete raw submission.
443    ///
444    /// The definition tree is walked in declaration order (controllers
445    /// precede their dependents by construction), visiting every submitted
446    /// occurrence of each repeatable group. For each field occurrence:
447    /// applicability is evaluated from earlier decoded values in the same
448    /// scope chain; an active field decodes via the shared field pipeline —
449    /// default resolution, kind conversion, constraints, application
450    /// validator; an inactive field must be blank or hold its untouched
451    /// pre-filled default, otherwise it is reported as
452    /// populated-but-inapplicable. Repeatable-group occurrence counts
453    /// outside the declared bounds are reported as structural diagnostics
454    /// while the occurrences that do exist still decode.
455    ///
456    /// All independent diagnostics accumulate: one pass reports every
457    /// missing value, conversion failure, constraint violation, field-
458    /// validation failure, populated inactive field, and occurrence-bound
459    /// violation together. A field whose controller errored is skipped
460    /// without piling on speculative diagnostics.
461    ///
462    /// # Errors
463    ///
464    /// The accumulated [`ValidationDiagnostic`] list, identifying fields by
465    /// stable occurrence path without echoing submitted values.
466    pub fn decode_answers(&self, raw: &RawAnswers) -> Result<Answers, Vec<ValidationDiagnostic>> {
467        let mut outcomes: BTreeMap<String, FieldOutcome> = BTreeMap::new();
468        let mut occurrences: BTreeMap<String, usize> = BTreeMap::new();
469        let mut diagnostics = Vec::new();
470
471        self.decode_items(
472            self.items(),
473            &mut vec![ScopeCtx::root()],
474            raw,
475            &mut outcomes,
476            &mut occurrences,
477            &mut diagnostics,
478        );
479
480        if !diagnostics.is_empty() {
481            return Err(diagnostics);
482        }
483        let values = outcomes
484            .into_iter()
485            .filter_map(|(path, outcome)| match outcome {
486                FieldOutcome::Answered(value) => Some((path, value)),
487                _ => None,
488            })
489            .collect();
490        Ok(Answers {
491            values,
492            occurrences,
493        })
494    }
495
496    /// Decode one scope's items; `chain` is the open scope chain, innermost
497    /// last (used to build occurrence paths and resolve controllers).
498    fn decode_items(
499        &self,
500        items: &[Item],
501        chain: &mut Vec<ScopeCtx>,
502        raw: &RawAnswers,
503        outcomes: &mut BTreeMap<String, FieldOutcome>,
504        occurrences: &mut BTreeMap<String, usize>,
505        diagnostics: &mut Vec<ValidationDiagnostic>,
506    ) {
507        for item in items {
508            match item {
509                Item::Field(field) => {
510                    let path = chain
511                        .last()
512                        .expect("chain starts rooted")
513                        .child_path(field.id());
514                    let raw_value = raw.get(&path);
515                    let outcome = match is_active(self, field, chain, outcomes) {
516                        None => FieldOutcome::Errored,
517                        Some(false) => {
518                            let blank = raw_value.is_none_or(|t| t.trim().is_empty());
519                            let untouched_default =
520                                field.default().is_some() && raw_value == field.default();
521                            if blank || untouched_default {
522                                FieldOutcome::Inactive
523                            } else {
524                                let condition =
525                                    field.condition().expect("inactive implies condition");
526                                diagnostics.push(ValidationDiagnostic::field(
527                                    path.clone(),
528                                    format!(
529                                        "this question does not apply (it is asked only when {} is {}); remove its answer or change the controlling answer.",
530                                        condition.controller(),
531                                        condition.expected()
532                                    ),
533                                ));
534                                FieldOutcome::Errored
535                            }
536                        }
537                        Some(true) => {
538                            let computed = field.dynamic_default().map(|dynamic| {
539                                dynamic.compute(&EarlierAnswers::new(self, chain, outcomes))
540                            });
541                            match decode_field(field, &path, raw_value, computed.as_deref()) {
542                                Ok(Some(value)) => FieldOutcome::Answered(value),
543                                Ok(None) => FieldOutcome::Omitted,
544                                Err(diagnostic) => {
545                                    diagnostics.push(diagnostic);
546                                    FieldOutcome::Errored
547                                }
548                            }
549                        }
550                    };
551                    outcomes.insert(path, outcome);
552                }
553                Item::Group(group) => {
554                    let base = chain
555                        .last()
556                        .expect("chain starts rooted")
557                        .child_path(group.id());
558                    match group.repeat() {
559                        None => {
560                            chain.push(ScopeCtx {
561                                group_id: Some(group.id().to_string()),
562                                def_prefix: group.def_prefix(),
563                                path_prefix: base,
564                            });
565                            self.decode_items(
566                                group.children(),
567                                chain,
568                                raw,
569                                outcomes,
570                                occurrences,
571                                diagnostics,
572                            );
573                            chain.pop();
574                        }
575                        Some(repeat) => {
576                            let count = raw.occurrence_count(&base);
577                            if count < repeat.min() {
578                                diagnostics.push(ValidationDiagnostic::field(
579                                    base.clone(),
580                                    format!(
581                                        "{count} of at least {} required item(s) submitted. Copy a complete group block - its heading line and its questions - for each missing item.",
582                                        repeat.min()
583                                    ),
584                                ));
585                            }
586                            if let Some(max) = repeat.max() {
587                                if count > max {
588                                    diagnostics.push(ValidationDiagnostic::field(
589                                        base.clone(),
590                                        format!(
591                                            "{count} items submitted, but at most {max} are accepted. Remove the extra group block(s)."
592                                        ),
593                                    ));
594                                }
595                            }
596                            if count > 0 {
597                                occurrences.insert(base.clone(), count);
598                            }
599                            for index in 0..count {
600                                chain.push(ScopeCtx {
601                                    group_id: Some(group.id().to_string()),
602                                    def_prefix: group.def_prefix(),
603                                    path_prefix: format!("{base}[{index}]"),
604                                });
605                                self.decode_items(
606                                    group.children(),
607                                    chain,
608                                    raw,
609                                    outcomes,
610                                    occurrences,
611                                    diagnostics,
612                                );
613                                chain.pop();
614                            }
615                        }
616                    }
617                }
618            }
619        }
620    }
621
622    /// Decode and validate a complete raw submission, then run the
623    /// application's whole-form rules over the successful result.
624    ///
625    /// Field-level diagnostics behave exactly as in
626    /// [`decode_answers`](Self::decode_answers). When the field stage
627    /// succeeds, `form` runs once over the decoded [`Answers`] and every
628    /// returned [`FormError`] accumulates as a
629    /// [`ValidationDiagnostic::Form`] — so a batch submission reports all of
630    /// its independent form-level findings together, in the same list and
631    /// format as field-level ones. Whole-form rules do not run over a
632    /// submission with field-level failures: they would be judging values
633    /// that do not exist.
634    ///
635    /// # Errors
636    ///
637    /// The accumulated [`ValidationDiagnostic`] list from whichever stages
638    /// could run.
639    pub fn decode_answers_with<F>(
640        &self,
641        raw: &RawAnswers,
642        form: F,
643    ) -> Result<Answers, Vec<ValidationDiagnostic>>
644    where
645        F: FnOnce(&Answers) -> Vec<FormError>,
646    {
647        let answers = self.decode_answers(raw)?;
648        let form_errors = form(&answers);
649        if form_errors.is_empty() {
650            return Ok(answers);
651        }
652        Err(form_errors
653            .into_iter()
654            .map(|e| ValidationDiagnostic::Form {
655                fields: e.fields,
656                message: e.message,
657            })
658            .collect())
659    }
660}