standout_input/questionnaire/
collect.rs1use std::path::Path;
2
3use crate::env::StdinReader;
4
5use super::definition::Questionnaire;
6use super::parse::{AnswerSheetDiagnostic, RawAnswers};
7
8#[cfg(feature = "simple-prompts")]
9use std::collections::BTreeMap;
10#[cfg(feature = "simple-prompts")]
11use std::sync::Arc;
12
13#[cfg(feature = "simple-prompts")]
14use super::definition::{Constraint, Group, Item, ScalarField, ScalarKind};
15
16#[cfg(feature = "simple-prompts")]
17use crate::sources::{RealTerminal, TerminalIO, TextPromptSource};
18#[cfg(feature = "simple-prompts")]
19use crate::InputError;
20
21#[cfg(feature = "simple-prompts")]
22use super::decode::{decode_field, is_active, parse_bool, EarlierAnswers, FieldOutcome, ScopeCtx};
23
24impl Questionnaire {
25 pub fn read_answer_sheet_file(
26 &self,
27 path: impl AsRef<Path>,
28 ) -> Result<RawAnswers, Vec<AnswerSheetDiagnostic>> {
29 let path = path.as_ref();
30 let text = std::fs::read_to_string(path).map_err(|error| {
31 vec![AnswerSheetDiagnostic::UnreadableDocument {
32 detail: format!("{}: {error}", path.display()),
33 }]
34 })?;
35 self.parse_answer_sheet(&text)
36 }
37
38 pub fn read_answer_sheet_stdin_with(
39 &self,
40 reader: &dyn StdinReader,
41 ) -> Result<RawAnswers, Vec<AnswerSheetDiagnostic>> {
42 if reader.is_terminal() {
43 return Err(vec![AnswerSheetDiagnostic::UnreadableDocument {
44 detail: "stdin is an interactive terminal; pipe an answer sheet or pass a file"
45 .to_string(),
46 }]);
47 }
48 let text = reader.read_to_string().map_err(|error| {
49 vec![AnswerSheetDiagnostic::UnreadableDocument {
50 detail: format!("stdin: {error}"),
51 }]
52 })?;
53 self.parse_answer_sheet(&text)
54 }
55
56 #[cfg(feature = "simple-prompts")]
57 pub fn collect_interactive(&self) -> Result<RawAnswers, InputError> {
58 self.collect_interactive_from(&crate::InputSources::from_process())
59 }
60
61 #[cfg(feature = "simple-prompts")]
62 pub fn collect_interactive_from(
63 &self,
64 sources: &crate::InputSources,
65 ) -> Result<RawAnswers, InputError> {
66 self.collect_interactive_with_terminal_from(Arc::new(RealTerminal), sources)
67 }
68
69 #[cfg(feature = "simple-prompts")]
70 pub fn collect_interactive_with_terminal<T: TerminalIO + 'static>(
71 &self,
72 terminal: Arc<T>,
73 ) -> Result<RawAnswers, InputError> {
74 self.collect_interactive_with_terminal_from(terminal, &crate::InputSources::from_process())
75 }
76
77 #[cfg(feature = "simple-prompts")]
78 pub fn collect_interactive_with_terminal_from<T: TerminalIO + 'static>(
79 &self,
80 terminal: Arc<T>,
81 sources: &crate::InputSources,
82 ) -> Result<RawAnswers, InputError> {
83 if sources.responder().is_none() && !terminal.is_terminal() {
84 return Err(InputError::NoInput);
85 }
86
87 let mut collector = Collector {
88 questionnaire: self,
89 terminal,
90 responder: sources.responder_arc(),
91 raw: BTreeMap::new(),
92 occurrences: BTreeMap::new(),
93 outcomes: BTreeMap::new(),
94 };
95 collector.collect_items(self.items(), &mut vec![ScopeCtx::root()])?;
96 Ok(RawAnswers::from_parts(collector.raw, collector.occurrences))
97 }
98}
99
100#[cfg(feature = "simple-prompts")]
101struct Collector<'a, T: TerminalIO + 'static> {
102 questionnaire: &'a Questionnaire,
103 terminal: Arc<T>,
104 responder: Option<std::sync::Arc<dyn crate::PromptResponder>>,
105 raw: BTreeMap<String, String>,
106 occurrences: BTreeMap<String, usize>,
107 outcomes: BTreeMap<String, FieldOutcome>,
108}
109
110#[cfg(feature = "simple-prompts")]
111impl<T: TerminalIO + 'static> Collector<'_, T> {
112 fn collect_items(
113 &mut self,
114 items: &[Item],
115 chain: &mut Vec<ScopeCtx>,
116 ) -> Result<(), InputError> {
117 for item in items {
118 match item {
119 Item::Field(field) => self.collect_field(field, chain)?,
120 Item::Group(group) => match group.repeat() {
121 None => {
122 let base = chain
123 .last()
124 .expect("chain starts rooted")
125 .child_path(group.id());
126 chain.push(scope_for(group, base));
127 self.collect_items(group.children(), chain)?;
128 chain.pop();
129 }
130 Some(repeat) => {
131 let base = chain
132 .last()
133 .expect("chain starts rooted")
134 .child_path(group.id());
135 let mut count = 0;
136 loop {
137 if count >= repeat.min()
138 && (repeat.max() == Some(count) || !self.ask_add_another(group)?)
139 {
140 break;
141 }
142 chain.push(scope_for(group, format!("{base}[{count}]")));
143 self.collect_items(group.children(), chain)?;
144 chain.pop();
145 count += 1;
146 }
147 self.occurrences.insert(base, count);
148 }
149 },
150 }
151 }
152 Ok(())
153 }
154
155 fn collect_field(&mut self, field: &ScalarField, chain: &[ScopeCtx]) -> Result<(), InputError> {
156 let path = chain
157 .last()
158 .expect("chain starts rooted")
159 .child_path(field.id());
160 if is_active(self.questionnaire, field, chain, &self.outcomes) != Some(true) {
161 self.outcomes.insert(path, FieldOutcome::Inactive);
162 return Ok(());
163 }
164
165 let computed = field.dynamic_default().map(|dynamic| {
166 dynamic.compute(&EarlierAnswers::new(
167 self.questionnaire,
168 chain,
169 &self.outcomes,
170 ))
171 });
172 let base = interactive_message(field, computed.as_deref());
173 let mut message = base.clone();
174 loop {
175 let response = self.prompt(message.clone())?;
176 let entered = response.clone().unwrap_or_default();
177 match decode_field(field, &path, Some(&entered), computed.as_deref()) {
178 Ok(outcome) => {
179 self.raw.insert(path.clone(), entered.trim().to_string());
180 self.outcomes.insert(
181 path,
182 match outcome {
183 Some(value) => FieldOutcome::Answered(value),
184 None => FieldOutcome::Omitted,
185 },
186 );
187 return Ok(());
188 }
189 Err(diagnostic) => {
190 if response.is_none() {
191 return Err(InputError::NoInput);
192 }
193 message = format!("{diagnostic} Try again: {base}");
194 }
195 }
196 }
197 }
198
199 fn ask_add_another(&mut self, group: &Group) -> Result<bool, InputError> {
200 let base = format!("Add another? {} (yes/no) ", group.prompt());
201 let mut message = base.clone();
202 loop {
203 match self.prompt(message.clone())? {
204 None => return Ok(false),
205 Some(entered) => match parse_bool(&entered) {
206 Some(answer) => return Ok(answer),
207 None if entered.trim().is_empty() => return Ok(false),
208 None => {
209 message = format!(
210 "Expected a yes/no answer (true, false, yes, no, y, or n). Try again: {base}"
211 );
212 }
213 },
214 }
215 }
216 }
217
218 fn prompt(&self, message: String) -> Result<Option<String>, InputError> {
219 let source = TextPromptSource::with_terminal(message, self.terminal.clone());
220 match &self.responder {
221 Some(responder) => {
222 let sources = crate::InputSources::from_process()
223 .with_responder(std::sync::Arc::clone(responder));
224 source.prompt_entry_from(&sources)
225 }
226 None => source.prompt_entry(),
227 }
228 }
229}
230
231#[cfg(feature = "simple-prompts")]
232fn scope_for(group: &Group, path_prefix: String) -> ScopeCtx {
233 ScopeCtx {
234 group_id: Some(group.id().to_string()),
235 def_prefix: group.def_prefix(),
236 path_prefix,
237 }
238}
239
240#[cfg(feature = "simple-prompts")]
241fn interactive_message(field: &ScalarField, computed: Option<&str>) -> String {
242 let mut message = field.prompt().to_string();
243 if let Some(Constraint::OneOf(choices)) = field.constraint() {
244 message.push_str(&format!(" ({})", choices.join(" / ")));
245 } else if field.kind() == ScalarKind::Bool {
246 message.push_str(" (yes/no)");
247 }
248 if let Some(default) = field.default().or(computed) {
249 message.push_str(&format!(" [default: {default}]"));
250 }
251 message.push(' ');
252 message
253}