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}