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