Skip to main content

standout_input/questionnaire/
collect.rs

1use 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}