standout_input/questionnaire/collect.rs
1//! Collection adapters: named file, explicit stdin, and interactive prompts.
2//!
3//! Each adapter is a thin normalizer. The file and stdin adapters read one
4//! complete answer-sheet document and hand it to the shared parser; the
5//! interactive adapter walks the fields through the existing prompt
6//! abstractions ([`TextPromptSource`] plus the process-global
7//! [`PromptResponder`](crate::PromptResponder) test seam). All three end at
8//! the same place — [`RawAnswers`] — and every answer they capture runs
9//! through the same field decoders and validators in
10//! [`decode`](super::decode). There is deliberately no adapter-specific
11//! conversion, message wording, or answer-source merging: one submission
12//! comes from exactly one source.
13//!
14//! Interactive collection keeps the immediate feedback loop: a decode or
15//! field-validation failure on an *entered* answer re-prompts the current
16//! question with the diagnostic, keeping every previously accepted answer.
17//! Cancellation (EOF / Ctrl+D or a responder `Cancel`) aborts the whole
18//! collection with [`InputError::PromptCancelled`]. A non-input outcome (a
19//! responder `Skip`, or mid-collection terminal loss) is not an entry: it
20//! follows the shared blank rule where blank resolves — default or omission
21//! — but on a required field without a default it terminates the pass with
22//! [`InputError::NoInput`] instead of re-prompting a source that will never
23//! answer.
24
25use std::path::Path;
26
27use crate::env::StdinReader;
28
29use super::definition::Questionnaire;
30use super::parse::{AnswerSheetDiagnostic, RawAnswers};
31
32#[cfg(feature = "simple-prompts")]
33use std::collections::BTreeMap;
34#[cfg(feature = "simple-prompts")]
35use std::sync::Arc;
36
37#[cfg(feature = "simple-prompts")]
38use super::definition::{Constraint, Group, Item, ScalarField, ScalarKind};
39
40#[cfg(feature = "simple-prompts")]
41use crate::sources::{RealTerminal, TerminalIO, TextPromptSource};
42#[cfg(feature = "simple-prompts")]
43use crate::InputError;
44
45#[cfg(feature = "simple-prompts")]
46use super::decode::{decode_field, is_active, parse_bool, EarlierAnswers, FieldOutcome, ScopeCtx};
47
48impl Questionnaire {
49 /// Read one complete answer sheet from a named file.
50 ///
51 /// The whole document is read and parsed in one pass; the result is the
52 /// same [`RawAnswers`] representation every collection path produces.
53 /// Validate it with [`decode_answers`](Self::decode_answers) (or
54 /// [`decode_answers_with`](Self::decode_answers_with)).
55 ///
56 /// # Errors
57 ///
58 /// [`AnswerSheetDiagnostic::UnreadableDocument`] when the file cannot be
59 /// read, otherwise the parser's accumulated diagnostics.
60 pub fn read_answer_sheet_file(
61 &self,
62 path: impl AsRef<Path>,
63 ) -> Result<RawAnswers, Vec<AnswerSheetDiagnostic>> {
64 let path = path.as_ref();
65 let text = std::fs::read_to_string(path).map_err(|error| {
66 vec![AnswerSheetDiagnostic::UnreadableDocument {
67 detail: format!("{}: {error}", path.display()),
68 }]
69 })?;
70 self.parse_answer_sheet(&text)
71 }
72
73 /// Read one complete answer sheet from explicitly requested stdin
74 /// (e.g. an `--answers -` style flag), against an explicit
75 /// [`StdinReader`] — [`DefaultStdin`](crate::env::DefaultStdin) for the
76 /// process's real stdin (which honors a test override installed via
77 /// [`set_default_stdin_reader`](crate::env::set_default_stdin_reader)),
78 /// or an injected reader in tests.
79 ///
80 /// Selecting stdin is an explicit caller decision — this adapter never
81 /// merges stdin answers with any other source.
82 ///
83 /// # Errors
84 ///
85 /// [`AnswerSheetDiagnostic::UnreadableDocument`] when stdin is an
86 /// interactive terminal (there is no piped document to read) or fails to
87 /// read, otherwise the parser's accumulated diagnostics.
88 pub fn read_answer_sheet_stdin_with(
89 &self,
90 reader: &dyn StdinReader,
91 ) -> Result<RawAnswers, Vec<AnswerSheetDiagnostic>> {
92 if reader.is_terminal() {
93 return Err(vec![AnswerSheetDiagnostic::UnreadableDocument {
94 detail: "stdin is an interactive terminal; pipe an answer sheet or pass a file"
95 .to_string(),
96 }]);
97 }
98 let text = reader.read_to_string().map_err(|error| {
99 vec![AnswerSheetDiagnostic::UnreadableDocument {
100 detail: format!("stdin: {error}"),
101 }]
102 })?;
103 self.parse_answer_sheet(&text)
104 }
105
106 /// Collect answers interactively, one prompt per applicable field
107 /// occurrence.
108 ///
109 /// Prompts through [`TextPromptSource`] on the real terminal — and
110 /// therefore through any installed
111 /// [`PromptResponder`](crate::PromptResponder), which is how tests drive
112 /// this without a TTY. Every entered answer runs through the same field
113 /// decoders and validators as file and stdin answers; a failure on an
114 /// entered answer is a *local, retryable* error — the question
115 /// re-prompts with the diagnostic and all previously accepted answers
116 /// are kept. A blank entry follows the shared blank rule (default —
117 /// static or computed — first, then omission, then a required-answer
118 /// re-prompt), a field with a
119 /// [`DynamicDefault`](super::DynamicDefault) shows its computed default
120 /// in the prompt message, and inactive conditional fields are skipped
121 /// without prompting.
122 ///
123 /// Groups walk their children in place. A repeatable group collects its
124 /// declared minimum number of occurrences, then asks a yes/no
125 /// "add another?" question (blank means no) before each further
126 /// occurrence, stopping unprompted at the declared maximum — so an
127 /// interactive submission always satisfies the declared bounds, exactly
128 /// like a well-formed answer sheet.
129 ///
130 /// The result is the same [`RawAnswers`] representation the document
131 /// adapters produce (each entry already field-valid, keyed by occurrence
132 /// path); run [`decode_answers`](Self::decode_answers) or
133 /// [`decode_answers_with`](Self::decode_answers_with) on it for typed
134 /// values and whole-form rules.
135 ///
136 /// # Errors
137 ///
138 /// - [`InputError::PromptCancelled`] when the user cancels (EOF/Ctrl+D).
139 /// - [`InputError::NoInput`] when stdin is not a terminal and no
140 /// responder is installed (interactive collection needs one or the
141 /// other; it never silently reads a piped document), or when a
142 /// non-input outcome (a responder `Skip`, or mid-collection terminal
143 /// loss) lands on a required field without a default — the pass
144 /// terminates rather than re-prompting a source that produced no
145 /// input.
146 /// - Any terminal I/O failure from the underlying prompt source.
147 #[cfg(feature = "simple-prompts")]
148 pub fn collect_interactive(&self) -> Result<RawAnswers, InputError> {
149 self.collect_interactive_with_terminal(Arc::new(RealTerminal))
150 }
151
152 /// [`collect_interactive`](Self::collect_interactive) against an
153 /// explicit shared terminal, for callers and tests that inject their own
154 /// [`TerminalIO`] (e.g. [`MockTerminal`](crate::MockTerminal)).
155 #[cfg(feature = "simple-prompts")]
156 pub fn collect_interactive_with_terminal<T: TerminalIO + 'static>(
157 &self,
158 terminal: Arc<T>,
159 ) -> Result<RawAnswers, InputError> {
160 if crate::responder::current_prompt_responder().is_none() && !terminal.is_terminal() {
161 return Err(InputError::NoInput);
162 }
163
164 let mut collector = Collector {
165 questionnaire: self,
166 terminal,
167 raw: BTreeMap::new(),
168 occurrences: BTreeMap::new(),
169 outcomes: BTreeMap::new(),
170 };
171 collector.collect_items(self.items(), &mut vec![ScopeCtx::root()])?;
172 Ok(RawAnswers::from_parts(collector.raw, collector.occurrences))
173 }
174}
175
176/// One interactive collection pass: the walk state threaded through the
177/// definition tree.
178#[cfg(feature = "simple-prompts")]
179struct Collector<'a, T: TerminalIO + 'static> {
180 questionnaire: &'a Questionnaire,
181 terminal: Arc<T>,
182 /// Accepted raw answer text per occurrence path.
183 raw: BTreeMap<String, String>,
184 /// Collected occurrences per repeatable-group path base.
185 occurrences: BTreeMap<String, usize>,
186 /// Decode outcomes per occurrence path, for condition evaluation.
187 outcomes: BTreeMap<String, FieldOutcome>,
188}
189
190#[cfg(feature = "simple-prompts")]
191impl<T: TerminalIO + 'static> Collector<'_, T> {
192 /// Walk one scope's items; `chain` is the open scope chain, innermost
193 /// last (used to build occurrence paths and resolve controllers).
194 fn collect_items(
195 &mut self,
196 items: &[Item],
197 chain: &mut Vec<ScopeCtx>,
198 ) -> Result<(), InputError> {
199 for item in items {
200 match item {
201 Item::Field(field) => self.collect_field(field, chain)?,
202 Item::Group(group) => match group.repeat() {
203 None => {
204 let base = chain
205 .last()
206 .expect("chain starts rooted")
207 .child_path(group.id());
208 chain.push(scope_for(group, base));
209 self.collect_items(group.children(), chain)?;
210 chain.pop();
211 }
212 Some(repeat) => {
213 let base = chain
214 .last()
215 .expect("chain starts rooted")
216 .child_path(group.id());
217 let mut count = 0;
218 loop {
219 if count >= repeat.min()
220 && (repeat.max() == Some(count) || !self.ask_add_another(group)?)
221 {
222 break;
223 }
224 chain.push(scope_for(group, format!("{base}[{count}]")));
225 self.collect_items(group.children(), chain)?;
226 chain.pop();
227 count += 1;
228 }
229 self.occurrences.insert(base, count);
230 }
231 },
232 }
233 }
234 Ok(())
235 }
236
237 /// Prompt for one field occurrence, retrying locally until an answer
238 /// decodes (inactive occurrences are skipped without prompting). A
239 /// blank *entry* follows the shared blank rule — default, then
240 /// omission — and re-prompts when the rule cannot resolve it (a
241 /// required field without a default). A non-input outcome — a
242 /// responder `Skip`, or mid-collection terminal loss — resolves
243 /// through the same blank rule, but when the rule cannot absorb it,
244 /// collection terminates with [`InputError::NoInput`] instead of
245 /// re-prompting a source that produced no input.
246 fn collect_field(&mut self, field: &ScalarField, chain: &[ScopeCtx]) -> Result<(), InputError> {
247 let path = chain
248 .last()
249 .expect("chain starts rooted")
250 .child_path(field.id());
251 // Interactive fields either decode or terminate the pass, so
252 // controllers are never in an errored state and applicability is
253 // always known.
254 if is_active(self.questionnaire, field, chain, &self.outcomes) != Some(true) {
255 self.outcomes.insert(path, FieldOutcome::Inactive);
256 return Ok(());
257 }
258
259 // Earlier fields in the walk are final by now, so the dynamic
260 // default is computed once and shown in the prompt message.
261 let computed = field.dynamic_default().map(|dynamic| {
262 dynamic.compute(&EarlierAnswers::new(
263 self.questionnaire,
264 chain,
265 &self.outcomes,
266 ))
267 });
268 let base = interactive_message(field, computed.as_deref());
269 let mut message = base.clone();
270 loop {
271 // A blank entry and a non-input outcome both resolve to an
272 // empty string: the shared blank rule then decides between
273 // default and omission. Where it cannot (required, no
274 // default), the decode error below re-prompts an entry but
275 // ends the pass on non-input.
276 let response = self.prompt(message.clone())?;
277 let entered = response.clone().unwrap_or_default();
278 match decode_field(field, &path, Some(&entered), computed.as_deref()) {
279 Ok(outcome) => {
280 self.raw.insert(path.clone(), entered.trim().to_string());
281 self.outcomes.insert(
282 path,
283 match outcome {
284 Some(value) => FieldOutcome::Answered(value),
285 None => FieldOutcome::Omitted,
286 },
287 );
288 return Ok(());
289 }
290 Err(diagnostic) => {
291 // No input arrived at all (a responder `Skip` or a lost
292 // terminal — not an entry): re-prompting would spin
293 // forever against a source that will never answer, so
294 // the pass ends cleanly instead.
295 if response.is_none() {
296 return Err(InputError::NoInput);
297 }
298 message = format!("{diagnostic} Try again: {base}");
299 }
300 }
301 }
302 }
303
304 /// Ask whether to collect one more occurrence of a repeatable group.
305 /// Blank (an empty entry or a non-input outcome) means no; a non-yes/no
306 /// entry re-asks with the diagnostic.
307 fn ask_add_another(&mut self, group: &Group) -> Result<bool, InputError> {
308 let base = format!("Add another? {} (yes/no) ", group.prompt());
309 let mut message = base.clone();
310 loop {
311 match self.prompt(message.clone())? {
312 None => return Ok(false),
313 Some(entered) => match parse_bool(&entered) {
314 Some(answer) => return Ok(answer),
315 None if entered.trim().is_empty() => return Ok(false),
316 None => {
317 message = format!(
318 "Expected a yes/no answer (true, false, yes, no, y, or n). Try again: {base}"
319 );
320 }
321 },
322 }
323 }
324 }
325
326 /// One prompt round trip: `Ok(Some(text))` is an entered answer (a
327 /// blank line arrives as `Some("")`), `Ok(None)` is a non-input outcome
328 /// (a responder `Skip`, or a lost terminal); cancellation and I/O
329 /// failures propagate.
330 fn prompt(&self, message: String) -> Result<Option<String>, InputError> {
331 TextPromptSource::with_terminal(message, self.terminal.clone()).prompt_entry()
332 }
333}
334
335/// The scope-chain entry for one group occurrence.
336#[cfg(feature = "simple-prompts")]
337fn scope_for(group: &Group, path_prefix: String) -> ScopeCtx {
338 ScopeCtx {
339 group_id: Some(group.id().to_string()),
340 def_prefix: group.def_prefix(),
341 path_prefix,
342 }
343}
344
345/// The cosmetic prompt message for one field: its wording plus entry hints
346/// (choices, the yes/no vocabulary, and the default — static, or the
347/// `computed` dynamic default for this occurrence).
348#[cfg(feature = "simple-prompts")]
349fn interactive_message(field: &ScalarField, computed: Option<&str>) -> String {
350 let mut message = field.prompt().to_string();
351 if let Some(Constraint::OneOf(choices)) = field.constraint() {
352 message.push_str(&format!(" ({})", choices.join(" / ")));
353 } else if field.kind() == ScalarKind::Bool {
354 message.push_str(" (yes/no)");
355 }
356 if let Some(default) = field.default().or(computed) {
357 message.push_str(&format!(" [default: {default}]"));
358 }
359 message.push(' ');
360 message
361}