standout_input/questionnaire/mod.rs
1//! Questionnaire answer sheets: render a prose questionnaire, collect
2//! answers interactively or from a document, and decode them by stable
3//! identity through one shared validation pipeline. Structs that derive
4//! `Questionnaire` lower to this same runtime model and fill themselves from
5//! validated [`Answers`] without a serde boundary.
6//!
7//! Long questionnaires are awkward as a sequence of terminal prompts. This
8//! module renders an application-defined questionnaire as a prose *answer
9//! sheet* — a document that reads as questions and answers, not as a
10//! configuration format — and collects answers from any of three sources:
11//! interactive prompts, a named answer file, or explicitly requested stdin.
12//! Every source normalizes into the same [`RawAnswers`] representation and
13//! decodes through the same field decoders and validators, so equivalent
14//! answers behave identically no matter how they arrived. Sources never
15//! merge: one submission comes from exactly one source.
16//!
17//! # Ownership boundary
18//!
19//! `standout-input` owns the reusable machinery: definition validation,
20//! deterministic rendering, parsing, collection adapters, shared field
21//! decoding and validation, derive-support traits, and diagnostics. The
22//! application owns everything else — its questionnaire definition, whole-form
23//! rules (supplied as a closure to [`Questionnaire::decode_answers_with`]),
24//! interactive flow, review, confirmation, and side effects. When the
25//! definition is derived, the conversion of decoded [`Answers`] into the
26//! application struct is generated by the derive rather than hand-written by
27//! the application.
28//!
29//! # The rendered format
30//!
31//! ```text
32//! #! standout-answers 1
33//! #! questionnaire: demo.profile
34//! #! fingerprint: sha256:…
35//!
36//! 1. What is your project called? (string) <id:project.name>
37//!
38//! 2. License. (mit, bsd, or gpl) <id:project.license>
39//! mit
40//!
41//! 3. Add any notes. (text, optional) <id:project.notes>
42//! ```
43//!
44//! The line-terminal `<id:...>` tag is the stable machine identity, and
45//! recognition is one rule: a line is a *question line* if and only if it
46//! ends with a tag as its last non-whitespace content — any trailing
47//! non-blank character, even a period, demotes the line to ordinary prose.
48//! The answer is all text between a question line and the next question
49//! line (or end of file); it keeps internal line breaks and loses only
50//! outer whitespace. Everything before the tag — the display number, the
51//! wording, indentation, and the parenthesized type hint — is cosmetic: a
52//! user (or a later release of the application) may reword, renumber, or
53//! re-indent freely without changing what the document means, and hints may
54//! contain any characters. Declared defaults render pre-filled as the
55//! answer text below their question line.
56//!
57//! One limitation is accepted by design: an answer line that itself ends
58//! with a schema-valid `<id:...>` tag is read as a question line — there is
59//! no escaping mechanism. As a guard, accepted answer text containing
60//! `<id:` anywhere (a mid-line mention, a mangled or half-deleted tag)
61//! raises a warning-level diagnostic ([`RawAnswers::warnings`]) without
62//! failing the submission.
63//!
64//! # Nested and repeatable groups
65//!
66//! A questionnaire is a tree: alongside scalar fields it may declare
67//! [`Group`]s — nested sections answered once, or *repeatable* sections
68//! answered once per submitted item within declared [`Repeat`] bounds. A
69//! questionnaire with a repeatable `command.inputs` group (minimum 1)
70//! renders as:
71//!
72//! ```text
73//! 2. Describe the initial command. (section) <id:command>
74//!
75//! 2.1 What is the command name? (string) <id:command.name>
76//!
77//! 2.2 Describe a command input. (repeatable section, minimum 1) <id:command.inputs>
78//! (Add an item by copying one complete block - its heading line and its
79//! questions - below the last block, then answering the copy.)
80//!
81//! 2.2.1 What is its name? (string) <id:command.inputs.name>
82//! ```
83//!
84//! Rendering emits exactly the declared minimum number of blocks per
85//! repeatable group. Adding an item is *copy-the-block* editing: copy one
86//! complete block — the group heading line and its questions — paste it
87//! below the last block, and answer the copy. Display numbers stay purely
88//! decorative: every copy may keep saying `2.2.1`, because the parser counts
89//! *lines ending with the stable group tag* and nothing else — never
90//! numbering, wording, or any count written in prose.
91//!
92//! A group occurrence is exactly a line ending with the group's tag; a
93//! field question line is recognized only where its definition places it.
94//! Mid-line tag mentions, bracketed prose, and `->` bullets inside answers
95//! are inert answer content.
96//!
97//! ## Definition IDs vs occurrence indexes
98//!
99//! Definition IDs never change: the name field of *every* submitted input
100//! is defined as `command.inputs.name`. A submitted *instance* of that
101//! field is addressed by its **occurrence path**, which inserts a
102//! zero-based index per enclosing repeatable-group occurrence: the second
103//! input's name is `command.inputs[1].name`. Diagnostics use occurrence
104//! paths, so an error points at the exact copied block to fix; [`Answers`]
105//! and [`RawAnswers`] are keyed by them and expose
106//! [`occurrence_count`](Answers::occurrence_count) for iterating submitted
107//! items. Indexes belong to an answer instance, never to the definition —
108//! which is why they participate in paths but not in the fingerprint.
109//!
110//! # Decoding: defaults, omission, conditions
111//!
112//! Decoding a submission ([`Questionnaire::decode_answers`]) applies one
113//! blank rule everywhere: a blank answer resolves to the declared default
114//! first; without a default, a blank optional field is an omission and a
115//! blank required field is a missing-value error. A conditional field
116//! ([`ScalarField::active_when`]) is asked and enforced only while its
117//! controller holds the expected value; an inactive field may stay blank
118//! (or keep its untouched pre-filled default), while a *populated* inactive
119//! field is an error — stale intent is never silently discarded.
120//!
121//! A default may also be *dynamic* ([`ScalarField::with_dynamic_default`]):
122//! a closure computing the default from earlier decoded answers in the same
123//! scope chain, paired with a mandatory declared revision that enters the
124//! fingerprint in place of a static value (closures cannot be hashed — the
125//! [`DynamicDefault`] revision contract mirrors [`FieldValidator`]'s).
126//! Dynamic-default fields render with an empty answer region — a sheet
127//! cannot pre-fill a value that depends on other answers — and a blank
128//! answer resolves through the computed default identically across
129//! interactive, file, and stdin collection; interactive prompts show the
130//! computed default. Like a condition, a dynamic default may only depend on
131//! earlier-declared fields ([`DynamicDefault`] documents the contract).
132//!
133//! Interactive collection gives immediate feedback: a failed *entered*
134//! answer re-prompts that one question, keeping earlier answers. A
135//! non-input outcome (a responder `Skip`, or mid-collection terminal loss)
136//! is not an entry: blank resolution still applies, but on a required field
137//! without a default it terminates the pass with an error instead of
138//! re-prompting a source that will never answer. Batch collection
139//! (file / stdin) reads the whole document and accumulates every
140//! independent diagnostic — syntax, identity, missing values, conversion,
141//! field validation, and the application's whole-form rules — in one pass,
142//! so a sheet can be repaired in one edit.
143//!
144//! # Compatibility: exact match, no migration
145//!
146//! The preamble pins an answer-format version, the questionnaire ID, and a
147//! semantic *fingerprint* of the definition. Parsing accepts only exact
148//! matches of all three; a stale sheet gets a diagnostic asking for a
149//! freshly rendered one, never a guessed field mapping. The fingerprint
150//! covers every semantic property that changes accepted answers — IDs,
151//! kinds, optionality, defaults (static values and declared dynamic-default
152//! revisions), constraints, conditions, and declared validator revisions —
153//! and ignores wording, numbering, and ordering. Copy
154//! edits keep old sheets valid; semantic changes reliably invalidate them.
155//!
156//! The fingerprint is a compatibility checksum only. It does not
157//! authenticate a document, detect tampering, or protect its content.
158//!
159//! # Sensitive content
160//!
161//! Answer sheets are plain text files that may hold whatever the questions
162//! ask for — including private or sensitive values. Treat a saved sheet with
163//! the same care as the answers themselves: keep it out of version control
164//! and world-readable locations, and delete it when done. Diagnostics from
165//! this module identify fields by ID and line number without echoing answer
166//! values; application validator and form messages should do the same.
167//!
168//! # Round-trip example
169//!
170//! ```
171//! use standout_input::questionnaire::{FormError, Questionnaire, ScalarField, ScalarKind};
172//!
173//! // The application owns this definition; IDs are the stable contract.
174//! let questionnaire = Questionnaire::new(
175//! "demo.profile",
176//! vec![
177//! ScalarField::new("project.name", "What is your project called?", ScalarKind::String),
178//! ScalarField::new("project.docker", "Use Docker?", ScalarKind::Bool)
179//! .with_default("no"),
180//! // Asked only when the controller above decodes to true.
181//! ScalarField::new("project.docker_image", "Base image?", ScalarKind::String)
182//! .active_when("project.docker", "yes"),
183//! ],
184//! )
185//! .unwrap();
186//!
187//! // Render the blank sheet, then simulate a user editing an answer in.
188//! // The docker default is pre-filled; leaving it means "no", so the
189//! // conditional image question may stay blank.
190//! let sheet = questionnaire.render_answer_sheet();
191//! let edited = sheet.replace(
192//! "<id:project.name>\n",
193//! "<id:project.name>\ndemo\n",
194//! );
195//!
196//! let raw = questionnaire.parse_answer_sheet(&edited).unwrap();
197//! let answers = questionnaire
198//! .decode_answers_with(&raw, |_answers| Vec::<FormError>::new())
199//! .unwrap();
200//! assert_eq!(answers.get_text("project.name"), Some("demo"));
201//! assert_eq!(answers.get_bool("project.docker"), Some(false));
202//! assert_eq!(answers.get("project.docker_image"), None); // inactive
203//! ```
204//!
205//! # Nested and repeatable round trip
206//!
207//! ```
208//! use standout_input::questionnaire::{Group, Item, Questionnaire, ScalarField, ScalarKind};
209//!
210//! let questionnaire = Questionnaire::new(
211//! "demo.commands",
212//! vec![
213//! Item::from(ScalarField::new(
214//! "command.name",
215//! "What is the command name?",
216//! ScalarKind::String,
217//! )),
218//! // A repeatable group: at least one input, each with two fields.
219//! Item::from(
220//! Group::new(
221//! "command.inputs",
222//! "Describe a command input.",
223//! vec![
224//! ScalarField::new("command.inputs.name", "Its name?", ScalarKind::String),
225//! ScalarField::new("command.inputs.value_type", "Its type?", ScalarKind::String)
226//! .with_default("string"),
227//! ],
228//! )
229//! .repeatable(1),
230//! ),
231//! ],
232//! )
233//! .unwrap();
234//!
235//! // Answer the sheet, then simulate copy-the-block editing: duplicate the
236//! // one rendered `command.inputs` block to submit a second item.
237//! let sheet = questionnaire
238//! .render_answer_sheet()
239//! .replace("<id:command.name>\n", "<id:command.name>\ngenerate\n")
240//! .replace("<id:command.inputs.name>\n", "<id:command.inputs.name>\ndefinition\n");
241//! let block_start = sheet.find("Describe a command input.").unwrap();
242//! let block = sheet[block_start..].to_string();
243//! let copied = format!("{sheet}\n{}", block.replace("\ndefinition\n", "\noutput\n"));
244//!
245//! let raw = questionnaire.parse_answer_sheet(&copied).unwrap();
246//! let answers = questionnaire.decode_answers(&raw).unwrap();
247//!
248//! // Occurrences are counted from the stable group header; each submitted
249//! // instance is addressed by its indexed occurrence path.
250//! assert_eq!(answers.occurrence_count("command.inputs"), 2);
251//! assert_eq!(answers.get_text("command.inputs[0].name"), Some("definition"));
252//! assert_eq!(answers.get_text("command.inputs[1].name"), Some("output"));
253//! assert_eq!(answers.get_text("command.inputs[1].value_type"), Some("string")); // default
254//! ```
255
256mod collect;
257mod decode;
258mod definition;
259mod derive;
260mod fingerprint;
261mod parse;
262mod render;
263
264pub use decode::{AnswerValue, Answers, EarlierAnswers, FormError, ValidationDiagnostic};
265pub use definition::{
266 Condition, Constraint, DynamicDefault, FieldValidator, Group, Item, Questionnaire,
267 QuestionnaireError, Repeat, ScalarField, ScalarKind,
268};
269pub use derive::{
270 QuestionnaireChoiceParseError, QuestionnaireChoices, QuestionnaireInput,
271 QuestionnaireInputError,
272};
273pub use parse::{AnswerSheetDiagnostic, RawAnswers};