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}