Skip to main content

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}