Skip to main content

standout_input/questionnaire/
parse.rs

1use std::collections::{BTreeMap, HashSet};
2
3use super::definition::{child_segment, path_join, Questionnaire};
4use super::render::{FINGERPRINT_PREFIX, FORMAT_LINE, QUESTIONNAIRE_PREFIX, TAG_OPEN};
5
6#[derive(Debug, Clone, Default, PartialEq, Eq)]
7pub struct RawAnswers {
8    values: BTreeMap<String, String>,
9    occurrences: BTreeMap<String, usize>,
10    warnings: Vec<AnswerSheetDiagnostic>,
11}
12
13impl RawAnswers {
14    #[cfg(feature = "simple-prompts")]
15    pub(crate) fn from_parts(
16        values: BTreeMap<String, String>,
17        occurrences: BTreeMap<String, usize>,
18    ) -> Self {
19        Self {
20            values,
21            occurrences,
22            warnings: Vec::new(),
23        }
24    }
25
26    pub fn get(&self, path: &str) -> Option<&str> {
27        self.values.get(path).map(String::as_str)
28    }
29
30    pub fn occurrence_count(&self, group_path: &str) -> usize {
31        self.occurrences.get(group_path).copied().unwrap_or(0)
32    }
33
34    pub fn warnings(&self) -> &[AnswerSheetDiagnostic] {
35        &self.warnings
36    }
37
38    pub fn set(&mut self, path: impl Into<String>, answer: impl Into<String>) {
39        self.values.insert(path.into(), answer.into());
40    }
41
42    pub fn set_occurrence_count(&mut self, group_path: impl Into<String>, count: usize) {
43        self.occurrences.insert(group_path.into(), count);
44    }
45
46    pub fn push_warning(&mut self, warning: AnswerSheetDiagnostic) {
47        self.warnings.push(warning);
48    }
49}
50
51/// How the bytes behind `--answers` become [`RawAnswers`]; the default is [`StandoutAnswerSheet`].
52pub trait AnswerSheetFormat {
53    fn parse(
54        &self,
55        questionnaire: &Questionnaire,
56        text: &str,
57    ) -> Result<RawAnswers, Vec<AnswerSheetDiagnostic>>;
58}
59
60/// The preamble/fingerprint sheet `questions` renders.
61pub struct StandoutAnswerSheet;
62
63impl AnswerSheetFormat for StandoutAnswerSheet {
64    fn parse(
65        &self,
66        questionnaire: &Questionnaire,
67        text: &str,
68    ) -> Result<RawAnswers, Vec<AnswerSheetDiagnostic>> {
69        questionnaire.parse_answer_sheet(text)
70    }
71}
72
73#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
74pub enum AnswerSheetDiagnostic {
75    #[error("{message}")]
76    Incompatible { message: String },
77
78    #[error("Line {line}: {message}")]
79    Tag { line: usize, message: String },
80
81    #[error("Line {line}: warning: the answer for '{path}' contains '<id:'. A tag only marks a question when it ends its line; if this was meant to be a question line, remove everything after the tag — if it is ordinary prose, ignore this warning.")]
82    SuspectedTagInAnswer { path: String, line: usize },
83
84    #[error("Could not read the answer sheet: {detail}")]
85    UnreadableDocument { detail: String },
86}
87
88impl AnswerSheetDiagnostic {
89    fn incompatible(message: impl Into<String>) -> Self {
90        Self::Incompatible {
91            message: message.into(),
92        }
93    }
94
95    fn tag(line: usize, message: impl Into<String>) -> Self {
96        Self::Tag {
97            line,
98            message: message.into(),
99        }
100    }
101
102    fn malformed_preamble(line: usize, detail: impl std::fmt::Display) -> Self {
103        Self::incompatible(format!(
104            "Line {line}: malformed answer-sheet preamble: {detail}. Render a fresh answer sheet and copy your answers into it."
105        ))
106    }
107}
108
109struct OpenAnswer {
110    path: Option<String>,
111    lines: Vec<(usize, String)>,
112}
113
114impl OpenAnswer {
115    fn flush_into(
116        self,
117        values: &mut BTreeMap<String, String>,
118        warnings: &mut Vec<AnswerSheetDiagnostic>,
119    ) {
120        let Some(path) = self.path else {
121            return;
122        };
123        for (index, line) in &self.lines {
124            if line.contains(TAG_OPEN) {
125                warnings.push(AnswerSheetDiagnostic::SuspectedTagInAnswer {
126                    path: path.clone(),
127                    line: index + 1,
128                });
129            }
130        }
131        let text = self
132            .lines
133            .into_iter()
134            .map(|(_, line)| line)
135            .collect::<Vec<_>>()
136            .join("\n");
137        values.insert(path, text.trim().to_string());
138    }
139}
140
141struct Scope {
142    group_id: String,
143    def_prefix: String,
144    path_prefix: String,
145    discard: bool,
146}
147
148fn terminal_tag(line: &str) -> Option<&str> {
149    let before_close = line.trim_end().strip_suffix('>')?;
150    let open = before_close.rfind(TAG_OPEN)?;
151    let id = &before_close[open + TAG_OPEN.len()..];
152    let valid = !id.is_empty()
153        && id
154            .chars()
155            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-'));
156    valid.then_some(id)
157}
158
159impl Questionnaire {
160    pub fn parse_answer_sheet(&self, text: &str) -> Result<RawAnswers, Vec<AnswerSheetDiagnostic>> {
161        let lines: Vec<&str> = text.lines().collect();
162        let body_start = self.check_preamble(&lines)?;
163        self.parse_body(&lines, body_start)
164    }
165
166    pub fn parse_answer_sheet_body(
167        &self,
168        text: &str,
169    ) -> Result<RawAnswers, Vec<AnswerSheetDiagnostic>> {
170        let lines: Vec<&str> = text.lines().collect();
171        self.parse_body(&lines, 0)
172    }
173
174    fn parse_body(
175        &self,
176        lines: &[&str],
177        body_start: usize,
178    ) -> Result<RawAnswers, Vec<AnswerSheetDiagnostic>> {
179        let mut diagnostics: Vec<AnswerSheetDiagnostic> = Vec::new();
180        let mut warnings: Vec<AnswerSheetDiagnostic> = Vec::new();
181        let mut values: BTreeMap<String, String> = BTreeMap::new();
182        let mut occurrences: BTreeMap<String, usize> = BTreeMap::new();
183        let mut seen_sections: HashSet<String> = HashSet::new();
184        let mut stack: Vec<Scope> = Vec::new();
185        let mut open: Option<OpenAnswer> = None;
186
187        for (index, line) in lines.iter().enumerate().skip(body_start) {
188            let Some(id) = terminal_tag(line) else {
189                if let Some(current) = open.as_mut() {
190                    current.lines.push((index, line.to_string()));
191                }
192                continue;
193            };
194
195            if let Some(previous) = open.take() {
196                previous.flush_into(&mut values, &mut warnings);
197            }
198            let is_group = self.node_meta(id).is_some_and(|meta| meta.group);
199            if is_group {
200                self.open_group(
201                    id,
202                    index,
203                    &mut stack,
204                    &mut occurrences,
205                    &mut seen_sections,
206                    &mut diagnostics,
207                );
208            } else {
209                let path = self.open_field(id, index, &mut stack, &values, &mut diagnostics);
210                open = Some(OpenAnswer {
211                    path,
212                    lines: Vec::new(),
213                });
214            }
215        }
216        if let Some(last) = open {
217            last.flush_into(&mut values, &mut warnings);
218        }
219
220        if diagnostics.is_empty() {
221            Ok(RawAnswers {
222                values,
223                occurrences,
224                warnings,
225            })
226        } else {
227            Err(diagnostics)
228        }
229    }
230
231    fn open_field(
232        &self,
233        id: &str,
234        line_index: usize,
235        stack: &mut Vec<Scope>,
236        values: &BTreeMap<String, String>,
237        diagnostics: &mut Vec<AnswerSheetDiagnostic>,
238    ) -> Option<String> {
239        let line = line_index + 1;
240        let Some(meta) = self.node_meta(id) else {
241            diagnostics.push(AnswerSheetDiagnostic::tag(
242                line,
243                format!("unknown question tag '<id:{id}>'. This questionnaire does not define that ID; if the line is prose, add any character after the tag, otherwise render a fresh answer sheet."),
244            ));
245            return None;
246        };
247        let Some(keep) = resolve_scope(stack, meta.parent.as_deref()) else {
248            diagnostics.push(AnswerSheetDiagnostic::tag(
249                line,
250                format!("misplaced '<id:{id}>'. That ID is not valid at this point of the sheet; keep each question inside its own group block, or render a fresh answer sheet to restore the structure."),
251            ));
252            return None;
253        };
254        stack.truncate(keep);
255        let (def_prefix, path_prefix, discard) = match stack.last() {
256            Some(scope) => (
257                scope.def_prefix.as_str(),
258                scope.path_prefix.as_str(),
259                scope.discard,
260            ),
261            None => ("", "", false),
262        };
263        if discard {
264            return None;
265        }
266        let path = path_join(path_prefix, child_segment(def_prefix, id));
267        if values.contains_key(&path) {
268            diagnostics.push(AnswerSheetDiagnostic::tag(
269                line,
270                format!("duplicate question '<id:{path}>'. Each question may be answered once per occurrence; remove the extra question line or copy the complete group block instead."),
271            ));
272            return None;
273        }
274        Some(path)
275    }
276
277    fn open_group(
278        &self,
279        id: &str,
280        line_index: usize,
281        stack: &mut Vec<Scope>,
282        occurrences: &mut BTreeMap<String, usize>,
283        seen_sections: &mut HashSet<String>,
284        diagnostics: &mut Vec<AnswerSheetDiagnostic>,
285    ) {
286        let line = line_index + 1;
287        let group = self
288            .group_def(id)
289            .expect("caller verified the ID names a group");
290        let parent = self
291            .node_meta(id)
292            .expect("known group has meta")
293            .parent
294            .clone();
295
296        let discard_scope = |discard: bool| Scope {
297            group_id: group.id().to_string(),
298            def_prefix: group.def_prefix(),
299            path_prefix: String::new(),
300            discard,
301        };
302
303        let Some(keep) = resolve_scope(stack, parent.as_deref()) else {
304            diagnostics.push(AnswerSheetDiagnostic::tag(
305                line,
306                format!("misplaced '<id:{id}>'. That ID is not valid at this point of the sheet; keep each question inside its own group block, or render a fresh answer sheet to restore the structure."),
307            ));
308            stack.push(discard_scope(true));
309            return;
310        };
311        stack.truncate(keep);
312        let (parent_def, parent_path, parent_discard) = match stack.last() {
313            Some(scope) => (
314                scope.def_prefix.as_str(),
315                scope.path_prefix.as_str(),
316                scope.discard,
317            ),
318            None => ("", "", false),
319        };
320        if parent_discard {
321            stack.push(discard_scope(true));
322            return;
323        }
324        let base = path_join(parent_path, child_segment(parent_def, id));
325        let path_prefix = match group.repeat() {
326            Some(_) => {
327                let count = occurrences.entry(base.clone()).or_insert(0);
328                let index = *count;
329                *count += 1;
330                format!("{base}[{index}]")
331            }
332            None => {
333                if !seen_sections.insert(base.clone()) {
334                    diagnostics.push(AnswerSheetDiagnostic::tag(
335                        line,
336                        format!("duplicate group '<id:{id}>'. This group is answered once; remove the extra block (only repeatable sections take copied blocks)."),
337                    ));
338                    stack.push(discard_scope(true));
339                    return;
340                }
341                base
342            }
343        };
344        stack.push(Scope {
345            group_id: group.id().to_string(),
346            def_prefix: group.def_prefix(),
347            path_prefix,
348            discard: false,
349        });
350    }
351
352    fn check_preamble(&self, lines: &[&str]) -> Result<usize, Vec<AnswerSheetDiagnostic>> {
353        let mut diagnostics = Vec::new();
354        let mut i = 0;
355
356        let next_content = |i: &mut usize| -> Option<usize> {
357            while *i < lines.len() && lines[*i].trim().is_empty() {
358                *i += 1;
359            }
360            (*i < lines.len()).then(|| {
361                let at = *i;
362                *i += 1;
363                at
364            })
365        };
366
367        match next_content(&mut i) {
368            Some(at) => {
369                let line = lines[at].trim();
370                if line != FORMAT_LINE {
371                    match line.strip_prefix("#! standout-answers ") {
372                        Some(version) => {
373                            diagnostics.push(AnswerSheetDiagnostic::incompatible(format!(
374                                "Unsupported answer-format version '{}' (this release reads only version 1). Render a fresh answer sheet; old sheets are not migrated.",
375                                version.trim()
376                            )))
377                        }
378                        None => diagnostics.push(AnswerSheetDiagnostic::malformed_preamble(
379                            at + 1,
380                            format!("expected '{FORMAT_LINE}'"),
381                        )),
382                    }
383                }
384            }
385            None => diagnostics.push(AnswerSheetDiagnostic::malformed_preamble(
386                lines.len() + 1,
387                format!("expected '{FORMAT_LINE}'"),
388            )),
389        }
390
391        let expect_keyed = |i: &mut usize,
392                            prefix: &str,
393                            diagnostics: &mut Vec<AnswerSheetDiagnostic>|
394         -> Option<String> {
395            match next_content(i) {
396                Some(at) => match lines[at].trim().strip_prefix(prefix) {
397                    Some(value) => Some(value.trim().to_string()),
398                    None => {
399                        diagnostics.push(AnswerSheetDiagnostic::malformed_preamble(
400                            at + 1,
401                            format!("expected '{prefix} ...'"),
402                        ));
403                        None
404                    }
405                },
406                None => {
407                    diagnostics.push(AnswerSheetDiagnostic::malformed_preamble(
408                        lines.len() + 1,
409                        format!("expected '{prefix} ...'"),
410                    ));
411                    None
412                }
413            }
414        };
415
416        if let Some(found) = expect_keyed(&mut i, QUESTIONNAIRE_PREFIX, &mut diagnostics) {
417            if found != self.id() {
418                diagnostics.push(AnswerSheetDiagnostic::incompatible(format!(
419                    "This answer sheet is for questionnaire '{found}', not '{expected}'. Render a fresh answer sheet for '{expected}'.",
420                    expected = self.id()
421                )));
422            }
423        }
424        if let Some(found) = expect_keyed(&mut i, FINGERPRINT_PREFIX, &mut diagnostics) {
425            if found != self.fingerprint() {
426                diagnostics.push(AnswerSheetDiagnostic::incompatible(format!(
427                    "This answer sheet was rendered from a different version of questionnaire semantics (fingerprint '{found}', expected '{expected}'). The questionnaire changed since this sheet was rendered; render a fresh answer sheet and copy your answers into it. Answers are not migrated.",
428                    expected = self.fingerprint()
429                )));
430            }
431        }
432
433        if diagnostics.is_empty() {
434            Ok(i)
435        } else {
436            Err(diagnostics)
437        }
438    }
439}
440
441fn resolve_scope(stack: &[Scope], parent: Option<&str>) -> Option<usize> {
442    match parent {
443        None => Some(0),
444        Some(parent) => stack
445            .iter()
446            .rposition(|scope| scope.group_id == parent)
447            .map(|found| found + 1),
448    }
449}