Skip to main content

standout_input/questionnaire/
definition.rs

1//! Questionnaire definitions: stable identities plus cosmetic wording.
2//!
3//! A [`Questionnaire`] is an application-owned, static description of the
4//! information to collect: a tree of [`Item`]s, where each item is either a
5//! [`ScalarField`] or a [`Group`] of nested items (optionally repeatable
6//! within declared bounds). The definition carries two very different kinds
7//! of data and the split is the point:
8//!
9//! - **Semantic** (identity-bearing): the questionnaire ID, each field's and
10//!   group's stable ID, group structure and [`Repeat`] bounds, each field's
11//!   [`ScalarKind`], its optionality, its declared default (a static value,
12//!   or the revision of a [`DynamicDefault`]), its [`Constraint`], its
13//!   conditional-applicability [`Condition`], and the revision of any
14//!   attached [`FieldValidator`]. These feed the
15//!   [fingerprint](Questionnaire::fingerprint) and determine how answers are
16//!   decoded and validated.
17//! - **Cosmetic** (presentation-only): question wording and item order.
18//!   Changing them never changes answer identity or the fingerprint.
19//!
20//! Definitions are validated at construction: [`Questionnaire::new`] rejects
21//! empty or malformed IDs, duplicate IDs, empty groups, invalid repeat
22//! bounds, child IDs that do not extend their group's ID, invalid defaults
23//! and constraints, and conditions that reference unknown, later-declared,
24//! or out-of-scope fields, so every constructed questionnaire can render,
25//! parse, and decode without further checks.
26//!
27//! # Definition IDs vs occurrence paths
28//!
29//! Every field and group declares one *stable definition ID* such as
30//! `command.inputs.name`. A submitted answer instead lives at an *occurrence
31//! path*: for scalar fields and fields inside non-repeatable groups the path
32//! equals the definition ID, while each occurrence of a repeatable group
33//! inserts a zero-based index — the second submitted input's name is
34//! `command.inputs[1].name`. Definition IDs never carry indexes; indexes
35//! belong to an answer instance, which is why every child of a group must
36//! extend its group's ID (`command.inputs` → `command.inputs.name`): the
37//! occurrence path is then always derivable from the definition.
38
39use std::collections::{HashMap, HashSet};
40use std::sync::Arc;
41
42use super::decode::{check_field_text, AnswerValue, EarlierAnswers};
43use super::fingerprint::compute_fingerprint;
44
45/// The kind of value a scalar field collects.
46///
47/// The kind is *semantic*: it participates in the questionnaire
48/// [fingerprint](Questionnaire::fingerprint), drives the rendered type hint,
49/// and selects the shared decoder every collection path uses. Interactive,
50/// file, and stdin answers for the same field always run through the same
51/// kind decoder, so equivalent raw text decodes identically everywhere.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ScalarKind {
54    /// A short, single-line value (rendered hint: `string`). A multi-line
55    /// answer is a decode error.
56    String,
57    /// Free-form prose that may span several lines (rendered hint: `text`).
58    Text,
59    /// A yes/no value (rendered hint: `bool`). Decodes `true`/`false`/
60    /// `yes`/`no`/`y`/`n` case-insensitively.
61    Bool,
62    /// A filesystem path (rendered hint: `path`). Decoded as a single-line
63    /// string; no filesystem checks are performed at decode time.
64    Path,
65}
66
67impl ScalarKind {
68    /// Stable name used in the fingerprint canonical form and type hints.
69    pub(crate) fn name(self) -> &'static str {
70        match self {
71            ScalarKind::String => "string",
72            ScalarKind::Text => "text",
73            ScalarKind::Bool => "bool",
74            ScalarKind::Path => "path",
75        }
76    }
77}
78
79/// A semantic constraint on the values a field accepts.
80///
81/// Constraints are checked by the shared decoder after kind conversion, so
82/// every collection path enforces them identically. They participate in the
83/// [fingerprint](Questionnaire::fingerprint).
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum Constraint {
86    /// The decoded answer must equal one of these values exactly.
87    ///
88    /// Choices must be unique, non-blank single lines with no outer
89    /// whitespace (answers are trimmed before matching, so anything else is
90    /// unsatisfiable); [`Questionnaire::new`] rejects violations. Choice
91    /// order is presentation-only (the fingerprint sorts it); the set of
92    /// choices is semantic.
93    OneOf(Vec<String>),
94}
95
96/// A static conditional-applicability rule: this field is asked (and may be
97/// required) only when a previously declared *controller* field decoded to
98/// an expected value.
99///
100/// Conditions are semantic: they participate in the
101/// [fingerprint](Questionnaire::fingerprint). The expected value is stored
102/// canonically (for a bool controller, `true`/`false` — so declaring
103/// `"yes"` and `"true"` produce the same fingerprint).
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct Condition {
106    pub(crate) controller: String,
107    pub(crate) expected: String,
108}
109
110impl Condition {
111    /// The stable ID of the controlling field.
112    pub fn controller(&self) -> &str {
113        &self.controller
114    }
115
116    /// The canonical expected value that activates the dependent field.
117    pub fn expected(&self) -> &str {
118        &self.expected
119    }
120}
121
122/// The shared closure type behind a [`FieldValidator`]: judges a decoded
123/// value, returning a user-facing message on rejection.
124type ValidatorCheck = Arc<dyn Fn(&AnswerValue) -> Result<(), String> + Send + Sync>;
125
126/// An application-supplied field validator with an explicit semantic
127/// revision.
128///
129/// The closure runs in the shared decode stage, so interactive, file, and
130/// stdin answers are validated identically. Because closure semantics cannot
131/// be fingerprinted, the application declares an explicit `revision` string
132/// that *does* enter the [fingerprint](Questionnaire::fingerprint): bump the
133/// revision whenever the validator's accepted values change, and previously
134/// rendered sheets are invalidated exactly like any other semantic change.
135///
136/// Error messages returned by the closure are shown to users in diagnostics;
137/// they should describe the rule without echoing the submitted value.
138#[derive(Clone)]
139pub struct FieldValidator {
140    revision: String,
141    check: ValidatorCheck,
142}
143
144impl FieldValidator {
145    /// Create a validator with a semantic `revision` and a `check` that
146    /// returns a user-facing message on rejection.
147    pub fn new(
148        revision: impl Into<String>,
149        check: impl Fn(&AnswerValue) -> Result<(), String> + Send + Sync + 'static,
150    ) -> Self {
151        Self {
152            revision: revision.into(),
153            check: Arc::new(check),
154        }
155    }
156
157    /// The semantic revision that participates in the fingerprint.
158    pub fn revision(&self) -> &str {
159        &self.revision
160    }
161
162    /// Run the validator against a decoded value.
163    pub(crate) fn check(&self, value: &AnswerValue) -> Result<(), String> {
164        (self.check)(value)
165    }
166}
167
168impl std::fmt::Debug for FieldValidator {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        f.debug_struct("FieldValidator")
171            .field("revision", &self.revision)
172            .finish_non_exhaustive()
173    }
174}
175
176/// Equality compares the semantic revision only; the closure itself is not
177/// comparable, and the revision is the declared semantic identity.
178impl PartialEq for FieldValidator {
179    fn eq(&self, other: &Self) -> bool {
180        self.revision == other.revision
181    }
182}
183
184impl Eq for FieldValidator {}
185
186/// The shared closure type behind a [`DynamicDefault`]: computes a default
187/// value from the earlier decoded answers in the same scope chain.
188type DefaultCompute = Arc<dyn Fn(&EarlierAnswers<'_>) -> String + Send + Sync>;
189
190/// An application-supplied dynamic default with an explicit semantic
191/// revision, computed from earlier answers instead of declared statically.
192///
193/// Real interactive flows need context-dependent defaults — a field whose
194/// sensible default depends on how an earlier question was answered. The
195/// closure receives an [`EarlierAnswers`] view of the decoded answers that
196/// precede this field in the same scope chain and returns the default text,
197/// which then runs through the exact same kind / constraint / validator
198/// pipeline as any submitted answer, identically across interactive, file,
199/// and stdin collection.
200///
201/// Because closure semantics cannot be fingerprinted, the application
202/// declares an explicit `revision` string that enters the
203/// [fingerprint](Questionnaire::fingerprint) *in place of* a static default
204/// value — exactly the [`FieldValidator`] revision contract: bump the
205/// revision whenever the computed defaults change, and previously rendered
206/// sheets are invalidated like any other semantic change. The closure itself
207/// never affects the fingerprint.
208///
209/// # Dependency contract
210///
211/// Like a [condition](ScalarField::active_when), a dynamic default may only
212/// depend on fields declared *before* its own field, in the same group or an
213/// enclosing one. Construction cannot introspect the closure to enforce
214/// this, so the walk order defines the failure behavior instead: the
215/// [`EarlierAnswers`] view resolves lookups against the answers decoded so
216/// far, and a later-declared, out-of-scope, unknown, unanswered, or inactive
217/// field simply reads as `None`. The closure must return a usable default
218/// for every combination of `None`s it can observe.
219#[derive(Clone)]
220pub struct DynamicDefault {
221    revision: String,
222    compute: DefaultCompute,
223}
224
225impl DynamicDefault {
226    /// Create a dynamic default with a semantic `revision` and a `compute`
227    /// closure that derives the default text from earlier answers.
228    pub fn new(
229        revision: impl Into<String>,
230        compute: impl Fn(&EarlierAnswers<'_>) -> String + Send + Sync + 'static,
231    ) -> Self {
232        Self {
233            revision: revision.into(),
234            compute: Arc::new(compute),
235        }
236    }
237
238    /// The semantic revision that participates in the fingerprint.
239    pub fn revision(&self) -> &str {
240        &self.revision
241    }
242
243    /// Compute the default from the earlier decoded answers.
244    pub(crate) fn compute(&self, earlier: &EarlierAnswers<'_>) -> String {
245        (self.compute)(earlier)
246    }
247}
248
249impl std::fmt::Debug for DynamicDefault {
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        f.debug_struct("DynamicDefault")
252            .field("revision", &self.revision)
253            .finish_non_exhaustive()
254    }
255}
256
257/// Equality compares the semantic revision only; the closure itself is not
258/// comparable, and the revision is the declared semantic identity.
259impl PartialEq for DynamicDefault {
260    fn eq(&self, other: &Self) -> bool {
261        self.revision == other.revision
262    }
263}
264
265impl Eq for DynamicDefault {}
266
267/// One scalar question in a questionnaire.
268///
269/// The `id` is the stable machine identity rendered as the line-terminal
270/// tag (`<id:project.name>`); the `prompt` is human wording and may be
271/// edited freely without affecting compatibility. Everything else — kind,
272/// optionality, default (static value or dynamic-default revision),
273/// constraint, condition, and validator revision — is semantic.
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub struct ScalarField {
276    pub(crate) id: String,
277    pub(crate) prompt: String,
278    pub(crate) kind: ScalarKind,
279    pub(crate) optional: bool,
280    pub(crate) default: Option<String>,
281    pub(crate) dynamic_default: Option<DynamicDefault>,
282    pub(crate) constraint: Option<Constraint>,
283    pub(crate) condition: Option<Condition>,
284    pub(crate) validator: Option<FieldValidator>,
285}
286
287impl ScalarField {
288    /// Create a required scalar field with a stable `id`, human `prompt`
289    /// wording, and answer `kind`.
290    ///
291    /// The `id` and all declared properties are validated when the field is
292    /// passed to [`Questionnaire::new`], not here.
293    pub fn new(id: impl Into<String>, prompt: impl Into<String>, kind: ScalarKind) -> Self {
294        Self {
295            id: id.into(),
296            prompt: prompt.into(),
297            kind,
298            optional: false,
299            default: None,
300            dynamic_default: None,
301            constraint: None,
302            condition: None,
303            validator: None,
304        }
305    }
306
307    /// Mark this field as optional (a blank answer without a default means
308    /// omission rather than a missing-value error).
309    ///
310    /// Optionality is semantic: it changes the fingerprint.
311    pub fn optional(mut self) -> Self {
312        self.optional = true;
313        self
314    }
315
316    /// Declare a static default value.
317    ///
318    /// The renderer pre-fills the default as the answer text below the
319    /// question line, and during decoding any blank answer resolves to the
320    /// default *before* optionality is considered. Defaults must be a
321    /// single line with no outer whitespace (parsed answers are trimmed)
322    /// and must themselves decode cleanly. Defaults are semantic: they
323    /// change the fingerprint. A field declares either a static default or
324    /// a [dynamic one](Self::with_dynamic_default), never both.
325    pub fn with_default(mut self, default: impl Into<String>) -> Self {
326        self.default = Some(default.into());
327        self
328    }
329
330    /// Declare a dynamic default, computed from earlier decoded answers.
331    ///
332    /// The same blank rule applies as for a static default — a blank answer
333    /// resolves through the *computed* default before optionality,
334    /// identically across interactive, file, and stdin collection — but the
335    /// rendered sheet leaves the answer region empty (a sheet cannot
336    /// pre-fill a value that depends on other answers), and interactive
337    /// prompting shows the computed default in the prompt message. The
338    /// declared revision is semantic and enters the fingerprint in place of
339    /// a static value; see [`DynamicDefault`] for the revision and
340    /// dependency contracts. A field declares either a static default or a
341    /// dynamic one, never both.
342    pub fn with_dynamic_default(mut self, dynamic_default: DynamicDefault) -> Self {
343        self.dynamic_default = Some(dynamic_default);
344        self
345    }
346
347    /// Constrain the answer to one of the given values.
348    ///
349    /// Checked after kind conversion by the shared decoder. Not applicable
350    /// to [`ScalarKind::Bool`] fields. Semantic: changes the fingerprint.
351    pub fn one_of(mut self, choices: impl IntoIterator<Item = impl Into<String>>) -> Self {
352        self.constraint = Some(Constraint::OneOf(
353            choices.into_iter().map(Into::into).collect(),
354        ));
355        self
356    }
357
358    /// Make this field applicable only when the earlier-declared `controller`
359    /// field decodes to `expected`.
360    ///
361    /// The controller must live in the same group as this field or in one of
362    /// its enclosing groups (so every submitted occurrence resolves it
363    /// unambiguously); a controller inside a repeatable group gates its
364    /// dependents *per occurrence*. An inactive field may stay blank (or
365    /// keep its untouched pre-filled default) even when required; a
366    /// *populated* inactive field is a validation error. Conditions are
367    /// semantic: they change the fingerprint.
368    pub fn active_when(
369        mut self,
370        controller: impl Into<String>,
371        expected: impl Into<String>,
372    ) -> Self {
373        self.condition = Some(Condition {
374            controller: controller.into(),
375            expected: expected.into(),
376        });
377        self
378    }
379
380    /// Attach an application validator with an explicit semantic revision.
381    ///
382    /// See [`FieldValidator`] for the revision contract.
383    pub fn with_validator(mut self, validator: FieldValidator) -> Self {
384        self.validator = Some(validator);
385        self
386    }
387
388    /// The stable field ID.
389    pub fn id(&self) -> &str {
390        &self.id
391    }
392
393    /// The human wording (cosmetic).
394    pub fn prompt(&self) -> &str {
395        &self.prompt
396    }
397
398    /// The answer kind (semantic).
399    pub fn kind(&self) -> ScalarKind {
400        self.kind
401    }
402
403    /// Whether a blank answer without a default means omission rather than a
404    /// missing value.
405    pub fn is_optional(&self) -> bool {
406        self.optional
407    }
408
409    /// The declared static default, if any (semantic).
410    pub fn default(&self) -> Option<&str> {
411        self.default.as_deref()
412    }
413
414    /// The declared dynamic default, if any (its revision is semantic).
415    pub fn dynamic_default(&self) -> Option<&DynamicDefault> {
416        self.dynamic_default.as_ref()
417    }
418
419    /// The declared constraint, if any (semantic).
420    pub fn constraint(&self) -> Option<&Constraint> {
421        self.constraint.as_ref()
422    }
423
424    /// The conditional-applicability rule, if any (semantic).
425    pub fn condition(&self) -> Option<&Condition> {
426        self.condition.as_ref()
427    }
428
429    /// The attached application validator, if any (its revision is
430    /// semantic).
431    pub fn validator(&self) -> Option<&FieldValidator> {
432        self.validator.as_ref()
433    }
434
435    /// The cosmetic type hint rendered before the question tag.
436    ///
437    /// Presentation-only: choices render in "a, b, or c" style, and
438    /// optionality and conditions are spelled out. Hints may contain any
439    /// characters — only the line-terminal tag structures a sheet, so a
440    /// hint can never look like a question tag to the parser.
441    pub(crate) fn type_hint(&self) -> String {
442        let mut hint = match &self.constraint {
443            Some(Constraint::OneOf(choices)) => join_or(choices),
444            None => self.kind.name().to_string(),
445        };
446        if self.optional {
447            hint.push_str(", optional");
448        }
449        if let Some(condition) = &self.condition {
450            hint.push_str(&format!(
451                "; only when {} is {}",
452                condition.controller, condition.expected
453            ));
454        }
455        hint
456    }
457}
458
459/// Join choices in prose style: `a`, `a or b`, `a, b, or c`.
460fn join_or(choices: &[String]) -> String {
461    match choices {
462        [] => String::new(),
463        [one] => one.clone(),
464        [a, b] => format!("{a} or {b}"),
465        [head @ .., last] => format!("{}, or {last}", head.join(", ")),
466    }
467}
468
469/// Repeat bounds for a repeatable [`Group`]: at least `min` occurrences
470/// (rendering emits exactly `min` blank blocks) and, when declared, at most
471/// `max`.
472///
473/// Bounds are semantic: they participate in the
474/// [fingerprint](Questionnaire::fingerprint). The minimum must be at least 1
475/// so a rendered sheet always contains one complete block to copy; an
476/// entirely optional list of items should live behind a conditional field or
477/// an optional child instead.
478#[derive(Debug, Clone, Copy, PartialEq, Eq)]
479pub struct Repeat {
480    pub(crate) min: usize,
481    pub(crate) max: Option<usize>,
482}
483
484impl Repeat {
485    /// The minimum number of occurrences (also the rendered count).
486    pub fn min(&self) -> usize {
487        self.min
488    }
489
490    /// The maximum number of occurrences, if bounded.
491    pub fn max(&self) -> Option<usize> {
492        self.max
493    }
494}
495
496/// A named group of nested questionnaire items.
497///
498/// The `id` is the stable machine identity rendered as the line-terminal
499/// tag (`<id:command.inputs>`); the `prompt` is human wording and may be
500/// edited freely. Every child's ID must extend the group's ID with a `.` segment
501/// (`command.inputs` → `command.inputs.name`), which keeps submitted
502/// occurrence paths derivable from definition IDs.
503///
504/// A plain group ([`Group::new`]) renders and is answered exactly once — it
505/// structures related questions under one heading. A *repeatable* group
506/// ([`Group::repeatable`]) is answered once per submitted occurrence:
507/// rendering emits exactly the declared minimum number of blank blocks, and
508/// a person adds items by copying a complete block (its heading line and its
509/// questions). The number of submitted items is inferred from occurrences of
510/// the stable group header, never from display numbers or wording.
511#[derive(Debug, Clone, PartialEq, Eq)]
512pub struct Group {
513    pub(crate) id: String,
514    pub(crate) prompt: String,
515    pub(crate) children: Vec<Item>,
516    pub(crate) repeat: Option<Repeat>,
517}
518
519impl Group {
520    /// Create a non-repeatable group with a stable `id`, human `prompt`
521    /// wording, and nested `children`.
522    ///
523    /// The `id`, the child-ID prefix rule, and all nested declarations are
524    /// validated when the group is passed to [`Questionnaire::new`], not
525    /// here.
526    pub fn new(
527        id: impl Into<String>,
528        prompt: impl Into<String>,
529        children: impl IntoIterator<Item = impl Into<Item>>,
530    ) -> Self {
531        Self {
532            id: id.into(),
533            prompt: prompt.into(),
534            children: children.into_iter().map(Into::into).collect(),
535            repeat: None,
536        }
537    }
538
539    /// Make this group repeatable with at least `min` occurrences
540    /// (`min >= 1`; rendering emits exactly `min` blank blocks).
541    ///
542    /// Repeat bounds are semantic: they change the fingerprint.
543    pub fn repeatable(mut self, min: usize) -> Self {
544        self.repeat = Some(Repeat { min, max: None });
545        self
546    }
547
548    /// Bound a repeatable group to at most `max` occurrences.
549    ///
550    /// Only meaningful after [`repeatable`](Self::repeatable);
551    /// [`Questionnaire::new`] rejects a maximum on a non-repeatable group or
552    /// a maximum below the minimum. Semantic: changes the fingerprint.
553    pub fn max_occurrences(mut self, max: usize) -> Self {
554        match &mut self.repeat {
555            Some(repeat) => repeat.max = Some(max),
556            // Recorded as an impossible bound so `Questionnaire::new` can
557            // reject it with a precise error instead of silently ignoring it.
558            None => {
559                self.repeat = Some(Repeat {
560                    min: 0,
561                    max: Some(max),
562                })
563            }
564        }
565        self
566    }
567
568    /// The stable group ID.
569    pub fn id(&self) -> &str {
570        &self.id
571    }
572
573    /// The human wording (cosmetic).
574    pub fn prompt(&self) -> &str {
575        &self.prompt
576    }
577
578    /// The nested items, in presentation order.
579    pub fn children(&self) -> &[Item] {
580        &self.children
581    }
582
583    /// The repeat bounds, or `None` for a group answered exactly once.
584    pub fn repeat(&self) -> Option<Repeat> {
585        self.repeat
586    }
587
588    /// The definition-ID prefix every child extends (`<id>.`).
589    pub(crate) fn def_prefix(&self) -> String {
590        format!("{}.", self.id)
591    }
592
593    /// The cosmetic type hint rendered before the question tag.
594    pub(crate) fn type_hint(&self) -> String {
595        match self.repeat {
596            None => "section".to_string(),
597            Some(Repeat { min, max: None }) => {
598                format!("repeatable section, minimum {min}")
599            }
600            Some(Repeat {
601                min,
602                max: Some(max),
603            }) => format!("repeatable section, minimum {min}, maximum {max}"),
604        }
605    }
606}
607
608/// One node of a questionnaire definition: a scalar question or a group of
609/// nested items.
610#[derive(Debug, Clone, PartialEq, Eq)]
611pub enum Item {
612    /// A single scalar question.
613    Field(ScalarField),
614    /// A (possibly repeatable) group of nested items.
615    Group(Group),
616}
617
618impl From<ScalarField> for Item {
619    fn from(field: ScalarField) -> Self {
620        Item::Field(field)
621    }
622}
623
624impl From<Group> for Item {
625    fn from(group: Group) -> Self {
626        Item::Group(group)
627    }
628}
629
630impl Item {
631    /// The stable ID of this item, field or group alike.
632    pub fn id(&self) -> &str {
633        match self {
634            Item::Field(field) => field.id(),
635            Item::Group(group) => group.id(),
636        }
637    }
638}
639
640/// Join an occurrence-path prefix and a child segment (`""` prefixes join to
641/// the bare segment, so root items keep their definition IDs as paths).
642pub(crate) fn path_join(prefix: &str, segment: &str) -> String {
643    if prefix.is_empty() {
644        segment.to_string()
645    } else {
646        format!("{prefix}.{segment}")
647    }
648}
649
650/// The path segment a child contributes under its group's definition-ID
651/// prefix (`command.inputs.` + `command.inputs.name` → `name`).
652pub(crate) fn child_segment<'a>(def_prefix: &str, id: &'a str) -> &'a str {
653    id.strip_prefix(def_prefix).unwrap_or(id)
654}
655
656/// A definition-time validation error.
657///
658/// Produced by [`Questionnaire::new`]; a constructed questionnaire is always
659/// internally consistent. These are developer-time errors — an application
660/// with a valid definition never sees them — so they carry a rendered
661/// `reason` rather than per-rule structure.
662#[derive(Debug, thiserror::Error, PartialEq, Eq)]
663pub enum QuestionnaireError {
664    /// The definition's overall structure is invalid: a malformed or
665    /// duplicate ID, an empty questionnaire or group, or a group child
666    /// whose ID does not extend its group's ID.
667    #[error("{reason}")]
668    Structure {
669        /// The violated construction rule.
670        reason: String,
671    },
672
673    /// One field's or group's declared semantics are invalid: repeat
674    /// bounds, a default, a constraint, a condition, or a hook revision.
675    #[error("{reason}")]
676    Item {
677        /// The stable ID of the field or group carrying the invalid
678        /// declaration.
679        id: String,
680        /// The violated construction rule.
681        reason: String,
682    },
683}
684
685impl QuestionnaireError {
686    /// A whole-definition structure error.
687    pub(crate) fn structure(reason: impl Into<String>) -> Self {
688        Self::Structure {
689            reason: reason.into(),
690        }
691    }
692
693    /// An error in one field's or group's declaration.
694    pub(crate) fn item(id: impl Into<String>, reason: impl Into<String>) -> Self {
695        Self::Item {
696            id: id.into(),
697            reason: reason.into(),
698        }
699    }
700}
701
702/// An application-owned questionnaire definition.
703///
704/// See the [module documentation](crate::questionnaire) for the ownership
705/// boundary, the rendered answer-sheet format, and the collection and
706/// decoding model.
707#[derive(Debug, Clone, PartialEq, Eq)]
708pub struct Questionnaire {
709    id: String,
710    items: Vec<Item>,
711    /// Every declared ID mapped to its structural position, for scope
712    /// resolution during parsing and decoding.
713    meta: HashMap<String, NodeMeta>,
714    fingerprint: String,
715}
716
717/// Structural position of one declared ID within the definition tree.
718#[derive(Debug, Clone, PartialEq, Eq)]
719pub(crate) struct NodeMeta {
720    /// The enclosing group's ID, or `None` at the questionnaire root.
721    pub(crate) parent: Option<String>,
722    /// Whether the ID names a group (vs a scalar field).
723    pub(crate) group: bool,
724}
725
726/// Per-field facts gathered during the structural pass, consumed by the
727/// semantic pass to validate and canonicalize conditions.
728struct FieldInfo {
729    /// Depth-first declaration index (controllers must come first).
730    dfs: usize,
731    /// The chain of enclosing group IDs, outermost first.
732    chain: Vec<String>,
733    kind: ScalarKind,
734    constraint: Option<Constraint>,
735}
736
737/// Returns `true` when `id` is a valid stable identifier.
738fn valid_id(id: &str) -> bool {
739    !id.is_empty()
740        && id
741            .chars()
742            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-'))
743}
744
745impl Questionnaire {
746    /// Create a validated questionnaire definition.
747    ///
748    /// `id` is the stable questionnaire identity written into every rendered
749    /// sheet's preamble. `items` — scalar fields and (possibly repeatable)
750    /// groups; a `Vec<ScalarField>` works directly for a flat questionnaire
751    /// — are rendered, collected, and decoded in the given order, but order
752    /// is cosmetic for identity: reordering items does not change the
753    /// [fingerprint](Self::fingerprint). The one ordering rule is
754    /// structural: a conditional field's controller must be declared before
755    /// it, in the same group or an enclosing one.
756    ///
757    /// Condition expected values are canonicalized here (a bool controller's
758    /// `"yes"` becomes `"true"`), so equivalent declarations fingerprint
759    /// identically.
760    ///
761    /// # Errors
762    ///
763    /// Returns a [`QuestionnaireError`] for an invalid questionnaire or item
764    /// ID, a duplicate ID, an empty item list, an empty group, invalid
765    /// repeat bounds, a child ID that does not extend its group's ID, a
766    /// field declaring both a static and a dynamic default, or an invalid
767    /// default, constraint, condition, validator revision, or
768    /// dynamic-default revision.
769    pub fn new(
770        id: impl Into<String>,
771        items: Vec<impl Into<Item>>,
772    ) -> Result<Self, QuestionnaireError> {
773        let id = id.into();
774        if !valid_id(&id) {
775            return Err(QuestionnaireError::structure(format!(
776                "Invalid questionnaire ID '{id}': IDs must be non-empty and use only a-z, 0-9, '.', '_', '-'."
777            )));
778        }
779        let mut items: Vec<Item> = items.into_iter().map(Into::into).collect();
780        if items.is_empty() {
781            return Err(QuestionnaireError::structure(
782                "A questionnaire must declare at least one item (field or group).",
783            ));
784        }
785
786        // Structural pass: IDs, duplicates, group shape, repeat bounds.
787        let mut meta = HashMap::new();
788        let mut field_info = HashMap::new();
789        collect_structure(
790            &items,
791            None,
792            &mut Vec::new(),
793            &mut meta,
794            &mut field_info,
795            &mut 0,
796        )?;
797
798        // Semantic pass: constraints, defaults, validators, conditions.
799        validate_fields(&mut items, &meta, &field_info)?;
800
801        let fingerprint = compute_fingerprint(&id, &items);
802        Ok(Self {
803            id,
804            items,
805            meta,
806            fingerprint,
807        })
808    }
809
810    /// The stable questionnaire ID.
811    pub fn id(&self) -> &str {
812        &self.id
813    }
814
815    /// The declared items, in presentation order.
816    pub fn items(&self) -> &[Item] {
817        &self.items
818    }
819
820    /// The semantic fingerprint (`sha256:<hex>`).
821    ///
822    /// The fingerprint is a compatibility checksum over the *semantic*
823    /// definition — questionnaire ID and each field's stable ID, kind,
824    /// optionality, default, constraint, condition, and validator revision.
825    /// It deliberately excludes wording, presentation order, and everything
826    /// else cosmetic, so copy-editing a questionnaire never invalidates
827    /// existing answer sheets, while any change to accepted answers reliably
828    /// does. It is **not** an authenticity or tamper-proofing mechanism.
829    pub fn fingerprint(&self) -> &str {
830        &self.fingerprint
831    }
832
833    /// Look up a group anywhere in the tree by stable ID.
834    pub(crate) fn group_def(&self, id: &str) -> Option<&Group> {
835        find_group(&self.items, id)
836    }
837
838    /// Structural position of a declared ID, or `None` for unknown IDs.
839    pub(crate) fn node_meta(&self, id: &str) -> Option<&NodeMeta> {
840        self.meta.get(id)
841    }
842}
843
844/// Depth-first group lookup by stable ID.
845fn find_group<'a>(items: &'a [Item], id: &str) -> Option<&'a Group> {
846    items.iter().find_map(|item| match item {
847        Item::Field(_) => None,
848        Item::Group(group) if group.id == id => Some(group),
849        Item::Group(group) => find_group(&group.children, id),
850    })
851}
852
853/// Structural validation walk: ID shape and uniqueness, the child-ID prefix
854/// rule, group non-emptiness, and repeat bounds. Fills `meta` (structural
855/// position per ID) and `field_info` (per-field facts for the semantic
856/// pass).
857fn collect_structure(
858    items: &[Item],
859    parent: Option<&str>,
860    chain: &mut Vec<String>,
861    meta: &mut HashMap<String, NodeMeta>,
862    field_info: &mut HashMap<String, FieldInfo>,
863    dfs: &mut usize,
864) -> Result<(), QuestionnaireError> {
865    for item in items {
866        let item_id = item.id();
867        if !valid_id(item_id) {
868            return Err(QuestionnaireError::structure(format!(
869                "Invalid ID '{item_id}': IDs must be non-empty and use only a-z, 0-9, '.', '_', '-'."
870            )));
871        }
872        if meta.contains_key(item_id) {
873            return Err(QuestionnaireError::structure(format!(
874                "Duplicate ID '{item_id}': stable IDs must be unique within a questionnaire."
875            )));
876        }
877        if let Some(parent) = parent {
878            let prefix = format!("{parent}.");
879            if !item_id.starts_with(&prefix) || item_id.len() == prefix.len() {
880                return Err(QuestionnaireError::structure(format!(
881                    "Item '{item_id}' inside group '{parent}' must extend the group's ID ('{parent}.<segment>') so submitted occurrence paths stay derivable from definition IDs."
882                )));
883            }
884        }
885        meta.insert(
886            item_id.to_string(),
887            NodeMeta {
888                parent: parent.map(str::to_string),
889                group: matches!(item, Item::Group(_)),
890            },
891        );
892        *dfs += 1;
893        match item {
894            Item::Field(field) => {
895                field_info.insert(
896                    field.id.clone(),
897                    FieldInfo {
898                        dfs: *dfs,
899                        chain: chain.clone(),
900                        kind: field.kind,
901                        constraint: field.constraint.clone(),
902                    },
903                );
904            }
905            Item::Group(group) => {
906                if group.children.is_empty() {
907                    return Err(QuestionnaireError::structure(format!(
908                        "Group '{}' declares no children: a group must contain at least one field or group.",
909                        group.id
910                    )));
911                }
912                if let Some(repeat) = group.repeat {
913                    if repeat.min == 0 {
914                        return Err(QuestionnaireError::item(
915                            group.id.clone(),
916                            format!("Invalid repeat bounds on group '{}': the minimum must be at least 1 — rendering emits exactly the minimum number of blocks, and a sheet needs one complete block to copy (declare repeatable(min) before max_occurrences)", group.id),
917                        ));
918                    }
919                    if let Some(max) = repeat.max {
920                        if max < repeat.min {
921                            return Err(QuestionnaireError::item(
922                                group.id.clone(),
923                                format!(
924                                    "Invalid repeat bounds on group '{}': the maximum ({max}) is below the minimum ({})",
925                                    group.id, repeat.min
926                                ),
927                            ));
928                        }
929                    }
930                }
931                chain.push(group.id.clone());
932                collect_structure(
933                    &group.children,
934                    Some(&group.id),
935                    chain,
936                    meta,
937                    field_info,
938                    dfs,
939                )?;
940                chain.pop();
941            }
942        }
943    }
944    Ok(())
945}
946
947/// Semantic validation walk: per-field constraints, defaults, validator
948/// revisions, and conditions (classified and canonicalized against the
949/// structural pass's facts).
950fn validate_fields(
951    items: &mut [Item],
952    meta: &HashMap<String, NodeMeta>,
953    field_info: &HashMap<String, FieldInfo>,
954) -> Result<(), QuestionnaireError> {
955    for item in items {
956        match item {
957            Item::Field(field) => {
958                validate_constraint(field)?;
959                let field_id = field.id.clone();
960                if let Some(condition) = &mut field.condition {
961                    validate_condition(&field_id, condition, meta, field_info)?;
962                }
963                if let Some(validator) = &field.validator {
964                    if validator.revision().is_empty() {
965                        return Err(QuestionnaireError::item(
966                            field.id.clone(),
967                            format!("Field '{}' attaches a validator with an empty revision: the revision is the validator's semantic identity and must be non-empty.", field.id),
968                        ));
969                    }
970                }
971                validate_default(field)?;
972            }
973            Item::Group(group) => {
974                validate_fields(&mut group.children, meta, field_info)?;
975            }
976        }
977    }
978    Ok(())
979}
980
981/// Reject constraints that can never apply to their field.
982fn validate_constraint(field: &ScalarField) -> Result<(), QuestionnaireError> {
983    let Some(Constraint::OneOf(choices)) = &field.constraint else {
984        return Ok(());
985    };
986    let invalid = |reason: &str| {
987        QuestionnaireError::item(
988            field.id.clone(),
989            format!("Invalid constraint on field '{}': {reason}", field.id),
990        )
991    };
992    if field.kind == ScalarKind::Bool {
993        return Err(invalid("a bool field cannot declare choices"));
994    }
995    if choices.is_empty() {
996        return Err(invalid("the choice list is empty"));
997    }
998    let mut unique = HashSet::new();
999    for choice in choices {
1000        if choice.trim().is_empty() || choice.contains('\n') {
1001            return Err(invalid("choices must be non-blank single lines"));
1002        }
1003        if choice != choice.trim() {
1004            return Err(invalid(
1005                "choices must carry no outer whitespace (answers are trimmed before matching, so such a choice is unsatisfiable)",
1006            ));
1007        }
1008        if !unique.insert(choice.as_str()) {
1009            return Err(invalid("choices must be unique"));
1010        }
1011    }
1012    Ok(())
1013}
1014
1015/// Check a condition's controller — it must be an earlier-declared field in
1016/// the dependent's own scope chain — and rewrite the expected value into
1017/// canonical form.
1018fn validate_condition(
1019    field_id: &str,
1020    condition: &mut Condition,
1021    meta: &HashMap<String, NodeMeta>,
1022    field_info: &HashMap<String, FieldInfo>,
1023) -> Result<(), QuestionnaireError> {
1024    let invalid = |reason: String| {
1025        QuestionnaireError::item(
1026            field_id,
1027            format!("Invalid condition on field '{field_id}': {reason}"),
1028        )
1029    };
1030    let dependent = &field_info[field_id];
1031    let Some(controller) = field_info.get(&condition.controller) else {
1032        if meta.contains_key(&condition.controller) {
1033            return Err(invalid(format!(
1034                "controller '{}' is a group; a controller must be a scalar field",
1035                condition.controller
1036            )));
1037        }
1038        return Err(QuestionnaireError::item(
1039            field_id,
1040            format!(
1041                "Field '{field_id}' is conditioned on unknown field '{}'.",
1042                condition.controller
1043            ),
1044        ));
1045    };
1046    // The controller's scope chain must enclose (or equal) the dependent's,
1047    // so every submitted occurrence resolves the controller unambiguously.
1048    let enclosing = controller.chain.len() <= dependent.chain.len()
1049        && dependent.chain[..controller.chain.len()] == controller.chain[..];
1050    if !enclosing {
1051        return Err(QuestionnaireError::item(
1052            field_id,
1053            format!(
1054                "Field '{field_id}' is conditioned on '{}', which is not in an enclosing scope. A controller must be declared in the same group as the dependent field or in one of its enclosing groups.",
1055                condition.controller
1056            ),
1057        ));
1058    }
1059    if controller.dfs > dependent.dfs {
1060        return Err(QuestionnaireError::item(
1061            field_id,
1062            format!(
1063                "Field '{field_id}' is conditioned on '{}', which is declared after it. Declare the controlling field first.",
1064                condition.controller
1065            ),
1066        ));
1067    }
1068    let controller_kind = &controller.kind;
1069    let controller_constraint = &controller.constraint;
1070    if *controller_kind == ScalarKind::Bool {
1071        match super::decode::parse_bool(&condition.expected) {
1072            Some(value) => condition.expected = if value { "true" } else { "false" }.to_string(),
1073            None => {
1074                return Err(invalid(format!(
1075                    "controller '{}' is a bool, but the expected value is not a yes/no value",
1076                    condition.controller
1077                )))
1078            }
1079        }
1080    } else if let Some(Constraint::OneOf(choices)) = controller_constraint {
1081        if !choices.contains(&condition.expected) {
1082            return Err(invalid(format!(
1083                "controller '{}' never accepts the expected value (its choices are: {})",
1084                condition.controller,
1085                choices.join(", ")
1086            )));
1087        }
1088    } else if condition.expected.is_empty() || condition.expected != condition.expected.trim() {
1089        return Err(invalid(format!(
1090            "controller '{}' never decodes to the expected value (decoded answers are non-blank and carry no outer whitespace)",
1091            condition.controller
1092        )));
1093    }
1094    Ok(())
1095}
1096
1097/// Reject defaults that could not survive the shared decoder, the
1098/// single-line pre-filled rendering, or the render/parse round trip (outer
1099/// whitespace is trimmed away at parse time) — and default declarations
1100/// that conflict (static and dynamic together) or carry an empty
1101/// dynamic-default revision. A dynamic default's *computed* values cannot
1102/// be validated here (the closure runs against answers that do not exist
1103/// yet); they are checked by the shared decoder at decode time instead.
1104fn validate_default(field: &ScalarField) -> Result<(), QuestionnaireError> {
1105    if let Some(dynamic) = &field.dynamic_default {
1106        if field.default.is_some() {
1107            return Err(QuestionnaireError::item(
1108                field.id.clone(),
1109                format!("Field '{}' declares both a static and a dynamic default: a field takes one or the other, never both.", field.id),
1110            ));
1111        }
1112        if dynamic.revision().is_empty() {
1113            return Err(QuestionnaireError::item(
1114                field.id.clone(),
1115                format!("Field '{}' attaches a dynamic default with an empty revision: the revision is the dynamic default's semantic identity and must be non-empty.", field.id),
1116            ));
1117        }
1118    }
1119    let Some(default) = &field.default else {
1120        return Ok(());
1121    };
1122    let invalid = |reason: String| {
1123        QuestionnaireError::item(
1124            field.id.clone(),
1125            format!("Invalid default on field '{}': {reason}", field.id),
1126        )
1127    };
1128    if default.trim().is_empty() {
1129        return Err(invalid("a default must be non-blank".to_string()));
1130    }
1131    if default != default.trim() {
1132        return Err(invalid(
1133            "a default must carry no outer whitespace (parsed answers are trimmed, so it could never survive a render/parse round trip)"
1134                .to_string(),
1135        ));
1136    }
1137    if default.contains('\n') {
1138        return Err(invalid(
1139            "a default must be a single line (it renders pre-filled below the question line)"
1140                .to_string(),
1141        ));
1142    }
1143    if let Err(diagnostic) = check_field_text(field, field.id(), default) {
1144        return Err(invalid(format!(
1145            "the default does not decode cleanly: {diagnostic}"
1146        )));
1147    }
1148    Ok(())
1149}