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, 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}