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
39#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
40pub enum AnswerSheetDiagnostic {
41    #[error("{message}")]
42    Incompatible { message: String },
43
44    #[error("Line {line}: {message}")]
45    Tag { line: usize, message: String },
46
47    #[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.")]
48    SuspectedTagInAnswer { path: String, line: usize },
49
50    #[error("Could not read the answer sheet: {detail}")]
51    UnreadableDocument { detail: String },
52}
53
54impl AnswerSheetDiagnostic {
55    fn incompatible(message: impl Into<String>) -> Self {
56        Self::Incompatible {
57            message: message.into(),
58        }
59    }
60
61    fn tag(line: usize, message: impl Into<String>) -> Self {
62        Self::Tag {
63            line,
64            message: message.into(),
65        }
66    }
67
68    fn malformed_preamble(line: usize, detail: impl std::fmt::Display) -> Self {
69        Self::incompatible(format!(
70            "Line {line}: malformed answer-sheet preamble: {detail}. Render a fresh answer sheet and copy your answers into it."
71        ))
72    }
73}
74
75struct OpenAnswer {
76    path: Option<String>,
77    lines: Vec<(usize, String)>,
78}
79
80impl OpenAnswer {
81    fn flush_into(
82        self,
83        values: &mut BTreeMap<String, String>,
84        warnings: &mut Vec<AnswerSheetDiagnostic>,
85    ) {
86        let Some(path) = self.path else {
87            return;
88        };
89        for (index, line) in &self.lines {
90            if line.contains(TAG_OPEN) {
91                warnings.push(AnswerSheetDiagnostic::SuspectedTagInAnswer {
92                    path: path.clone(),
93                    line: index + 1,
94                });
95            }
96        }
97        let text = self
98            .lines
99            .into_iter()
100            .map(|(_, line)| line)
101            .collect::<Vec<_>>()
102            .join("\n");
103        values.insert(path, text.trim().to_string());
104    }
105}
106
107struct Scope {
108    group_id: String,
109    def_prefix: String,
110    path_prefix: String,
111    discard: bool,
112}
113
114fn terminal_tag(line: &str) -> Option<&str> {
115    let before_close = line.trim_end().strip_suffix('>')?;
116    let open = before_close.rfind(TAG_OPEN)?;
117    let id = &before_close[open + TAG_OPEN.len()..];
118    let valid = !id.is_empty()
119        && id
120            .chars()
121            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-'));
122    valid.then_some(id)
123}
124
125impl Questionnaire {
126    pub fn parse_answer_sheet(&self, text: &str) -> Result<RawAnswers, Vec<AnswerSheetDiagnostic>> {
127        let lines: Vec<&str> = text.lines().collect();
128        let body_start = self.check_preamble(&lines)?;
129
130        let mut diagnostics: Vec<AnswerSheetDiagnostic> = Vec::new();
131        let mut warnings: Vec<AnswerSheetDiagnostic> = Vec::new();
132        let mut values: BTreeMap<String, String> = BTreeMap::new();
133        let mut occurrences: BTreeMap<String, usize> = BTreeMap::new();
134        let mut seen_sections: HashSet<String> = HashSet::new();
135        let mut stack: Vec<Scope> = Vec::new();
136        let mut open: Option<OpenAnswer> = None;
137
138        for (index, line) in lines.iter().enumerate().skip(body_start) {
139            let Some(id) = terminal_tag(line) else {
140                if let Some(current) = open.as_mut() {
141                    current.lines.push((index, line.to_string()));
142                }
143                continue;
144            };
145
146            if let Some(previous) = open.take() {
147                previous.flush_into(&mut values, &mut warnings);
148            }
149            let is_group = self.node_meta(id).is_some_and(|meta| meta.group);
150            if is_group {
151                self.open_group(
152                    id,
153                    index,
154                    &mut stack,
155                    &mut occurrences,
156                    &mut seen_sections,
157                    &mut diagnostics,
158                );
159            } else {
160                let path = self.open_field(id, index, &mut stack, &values, &mut diagnostics);
161                open = Some(OpenAnswer {
162                    path,
163                    lines: Vec::new(),
164                });
165            }
166        }
167        if let Some(last) = open {
168            last.flush_into(&mut values, &mut warnings);
169        }
170
171        if diagnostics.is_empty() {
172            Ok(RawAnswers {
173                values,
174                occurrences,
175                warnings,
176            })
177        } else {
178            Err(diagnostics)
179        }
180    }
181
182    fn open_field(
183        &self,
184        id: &str,
185        line_index: usize,
186        stack: &mut Vec<Scope>,
187        values: &BTreeMap<String, String>,
188        diagnostics: &mut Vec<AnswerSheetDiagnostic>,
189    ) -> Option<String> {
190        let line = line_index + 1;
191        let Some(meta) = self.node_meta(id) else {
192            diagnostics.push(AnswerSheetDiagnostic::tag(
193                line,
194                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."),
195            ));
196            return None;
197        };
198        let Some(keep) = resolve_scope(stack, meta.parent.as_deref()) else {
199            diagnostics.push(AnswerSheetDiagnostic::tag(
200                line,
201                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."),
202            ));
203            return None;
204        };
205        stack.truncate(keep);
206        let (def_prefix, path_prefix, discard) = match stack.last() {
207            Some(scope) => (
208                scope.def_prefix.as_str(),
209                scope.path_prefix.as_str(),
210                scope.discard,
211            ),
212            None => ("", "", false),
213        };
214        if discard {
215            return None;
216        }
217        let path = path_join(path_prefix, child_segment(def_prefix, id));
218        if values.contains_key(&path) {
219            diagnostics.push(AnswerSheetDiagnostic::tag(
220                line,
221                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."),
222            ));
223            return None;
224        }
225        Some(path)
226    }
227
228    fn open_group(
229        &self,
230        id: &str,
231        line_index: usize,
232        stack: &mut Vec<Scope>,
233        occurrences: &mut BTreeMap<String, usize>,
234        seen_sections: &mut HashSet<String>,
235        diagnostics: &mut Vec<AnswerSheetDiagnostic>,
236    ) {
237        let line = line_index + 1;
238        let group = self
239            .group_def(id)
240            .expect("caller verified the ID names a group");
241        let parent = self
242            .node_meta(id)
243            .expect("known group has meta")
244            .parent
245            .clone();
246
247        let discard_scope = |discard: bool| Scope {
248            group_id: group.id().to_string(),
249            def_prefix: group.def_prefix(),
250            path_prefix: String::new(),
251            discard,
252        };
253
254        let Some(keep) = resolve_scope(stack, parent.as_deref()) else {
255            diagnostics.push(AnswerSheetDiagnostic::tag(
256                line,
257                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."),
258            ));
259            stack.push(discard_scope(true));
260            return;
261        };
262        stack.truncate(keep);
263        let (parent_def, parent_path, parent_discard) = match stack.last() {
264            Some(scope) => (
265                scope.def_prefix.as_str(),
266                scope.path_prefix.as_str(),
267                scope.discard,
268            ),
269            None => ("", "", false),
270        };
271        if parent_discard {
272            stack.push(discard_scope(true));
273            return;
274        }
275        let base = path_join(parent_path, child_segment(parent_def, id));
276        let path_prefix = match group.repeat() {
277            Some(_) => {
278                let count = occurrences.entry(base.clone()).or_insert(0);
279                let index = *count;
280                *count += 1;
281                format!("{base}[{index}]")
282            }
283            None => {
284                if !seen_sections.insert(base.clone()) {
285                    diagnostics.push(AnswerSheetDiagnostic::tag(
286                        line,
287                        format!("duplicate group '<id:{id}>'. This group is answered once; remove the extra block (only repeatable sections take copied blocks)."),
288                    ));
289                    stack.push(discard_scope(true));
290                    return;
291                }
292                base
293            }
294        };
295        stack.push(Scope {
296            group_id: group.id().to_string(),
297            def_prefix: group.def_prefix(),
298            path_prefix,
299            discard: false,
300        });
301    }
302
303    fn check_preamble(&self, lines: &[&str]) -> Result<usize, Vec<AnswerSheetDiagnostic>> {
304        let mut diagnostics = Vec::new();
305        let mut i = 0;
306
307        let next_content = |i: &mut usize| -> Option<usize> {
308            while *i < lines.len() && lines[*i].trim().is_empty() {
309                *i += 1;
310            }
311            (*i < lines.len()).then(|| {
312                let at = *i;
313                *i += 1;
314                at
315            })
316        };
317
318        match next_content(&mut i) {
319            Some(at) => {
320                let line = lines[at].trim();
321                if line != FORMAT_LINE {
322                    match line.strip_prefix("#! standout-answers ") {
323                        Some(version) => {
324                            diagnostics.push(AnswerSheetDiagnostic::incompatible(format!(
325                                "Unsupported answer-format version '{}' (this release reads only version 1). Render a fresh answer sheet; old sheets are not migrated.",
326                                version.trim()
327                            )))
328                        }
329                        None => diagnostics.push(AnswerSheetDiagnostic::malformed_preamble(
330                            at + 1,
331                            format!("expected '{FORMAT_LINE}'"),
332                        )),
333                    }
334                }
335            }
336            None => diagnostics.push(AnswerSheetDiagnostic::malformed_preamble(
337                lines.len() + 1,
338                format!("expected '{FORMAT_LINE}'"),
339            )),
340        }
341
342        let expect_keyed = |i: &mut usize,
343                            prefix: &str,
344                            diagnostics: &mut Vec<AnswerSheetDiagnostic>|
345         -> Option<String> {
346            match next_content(i) {
347                Some(at) => match lines[at].trim().strip_prefix(prefix) {
348                    Some(value) => Some(value.trim().to_string()),
349                    None => {
350                        diagnostics.push(AnswerSheetDiagnostic::malformed_preamble(
351                            at + 1,
352                            format!("expected '{prefix} ...'"),
353                        ));
354                        None
355                    }
356                },
357                None => {
358                    diagnostics.push(AnswerSheetDiagnostic::malformed_preamble(
359                        lines.len() + 1,
360                        format!("expected '{prefix} ...'"),
361                    ));
362                    None
363                }
364            }
365        };
366
367        if let Some(found) = expect_keyed(&mut i, QUESTIONNAIRE_PREFIX, &mut diagnostics) {
368            if found != self.id() {
369                diagnostics.push(AnswerSheetDiagnostic::incompatible(format!(
370                    "This answer sheet is for questionnaire '{found}', not '{expected}'. Render a fresh answer sheet for '{expected}'.",
371                    expected = self.id()
372                )));
373            }
374        }
375        if let Some(found) = expect_keyed(&mut i, FINGERPRINT_PREFIX, &mut diagnostics) {
376            if found != self.fingerprint() {
377                diagnostics.push(AnswerSheetDiagnostic::incompatible(format!(
378                    "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.",
379                    expected = self.fingerprint()
380                )));
381            }
382        }
383
384        if diagnostics.is_empty() {
385            Ok(i)
386        } else {
387            Err(diagnostics)
388        }
389    }
390}
391
392fn resolve_scope(stack: &[Scope], parent: Option<&str>) -> Option<usize> {
393    match parent {
394        None => Some(0),
395        Some(parent) => stack
396            .iter()
397            .rposition(|scope| scope.group_id == parent)
398            .map(|found| found + 1),
399    }
400}