Skip to main content

standout_input/questionnaire/
parse.rs

1//! Parsing edited answer sheets back into raw answers.
2//!
3//! Parsing recognizes structure from a single rule: a line is a *question
4//! line* if and only if it ends with a well-formed `<id:...>` tag — the tag
5//! is the last non-whitespace content on the line. Any non-blank character
6//! after the tag, even a period, demotes the whole line to ordinary prose.
7//! An answer is all text between a question line and the next question line
8//! (or end of file), outer whitespace trimmed, internal line breaks
9//! preserved. A line whose terminal tag names a group opens one group
10//! occurrence; occurrence counting is simply counting the group's tag
11//! lines, exactly as copy-the-block editing implies. Everything before the
12//! tag on a question line — display numbers, wording, indentation, type
13//! hints — is cosmetic and freely editable, so a user may reword or
14//! renumber a sheet without changing what it means.
15//!
16//! One limitation is accepted by design: an answer whose own line ends with
17//! a schema-valid `<id:...>` tag is misparsed as a question line. There is
18//! deliberately no escaping mechanism — the shape is rare in prose. As a
19//! guard, accepted answer text containing `<id:` anywhere raises a
20//! warning-level diagnostic ([`RawAnswers::warnings`]), which also catches
21//! mangled or half-deleted tags.
22//!
23//! Repeated items are counted from occurrences of the stable group tag,
24//! never from display numbers or wording. Each occurrence of a repeatable
25//! group gives its answers an indexed *occurrence path* (`command.inputs`
26//! occurrence 1 holds `command.inputs[1].name`); fields outside repeatable
27//! groups keep their definition IDs as paths.
28//!
29//! Compatibility is exact-version: the preamble's answer-format version,
30//! questionnaire ID, and fingerprint must all match the parsing definition,
31//! or parsing stops with diagnostics that ask for a freshly rendered sheet.
32//! No migration or fuzzy matching is attempted.
33
34use std::collections::{BTreeMap, HashSet};
35
36use super::definition::{child_segment, path_join, Questionnaire};
37use super::render::{FINGERPRINT_PREFIX, FORMAT_LINE, QUESTIONNAIRE_PREFIX, TAG_OPEN};
38
39/// The raw answers parsed from one answer sheet.
40///
41/// Values are keyed by *occurrence path* — the stable field ID, with a
42/// zero-based index inserted for every enclosing repeatable-group occurrence
43/// (`command.inputs[1].name`) — and hold the verbatim answer text with outer
44/// whitespace trimmed and internal line breaks preserved. A field absent
45/// from the document is absent here; a field whose answer area was left
46/// blank is present with an empty string. Occurrence counts of repeatable
47/// groups are carried alongside ([`occurrence_count`](Self::occurrence_count)),
48/// as are any warning-level diagnostics ([`warnings`](Self::warnings)).
49/// Decoding raw text into typed values (defaults, omission, validation) is a
50/// later stage, shared with interactive collection.
51#[derive(Debug, Clone, Default, PartialEq, Eq)]
52pub struct RawAnswers {
53    values: BTreeMap<String, String>,
54    /// Occurrences per repeatable group, keyed by the group's own occurrence
55    /// path base (`command.inputs`, or `command.inputs[0].flags` when
56    /// nested). Groups with no submitted occurrence are absent.
57    occurrences: BTreeMap<String, usize>,
58    /// Warning-level diagnostics that do not fail the parse (currently:
59    /// accepted answer text containing a `<id:` tag fragment).
60    warnings: Vec<AnswerSheetDiagnostic>,
61}
62
63impl RawAnswers {
64    /// Build raw answers directly, for collection paths (interactive
65    /// prompting) that never see a document. Keys are occurrence paths;
66    /// values are trimmed answer text; `occurrences` counts each repeatable
67    /// group's collected occurrences by path base. Interactive answers are
68    /// decoded as they are entered, so they carry no document warnings.
69    #[cfg(feature = "simple-prompts")]
70    pub(crate) fn from_parts(
71        values: BTreeMap<String, String>,
72        occurrences: BTreeMap<String, usize>,
73    ) -> Self {
74        Self {
75            values,
76            occurrences,
77            warnings: Vec::new(),
78        }
79    }
80
81    /// The raw answer text at an occurrence path (for fields outside
82    /// repeatable groups: the stable field ID), if it appeared.
83    pub fn get(&self, path: &str) -> Option<&str> {
84        self.values.get(path).map(String::as_str)
85    }
86
87    /// How many occurrences of a repeatable group appeared, addressed by the
88    /// group's occurrence path base — `command.inputs` at the root,
89    /// `command.inputs[0].flags` for a group nested in another occurrence.
90    pub fn occurrence_count(&self, group_path: &str) -> usize {
91        self.occurrences.get(group_path).copied().unwrap_or(0)
92    }
93
94    /// Warning-level diagnostics from parsing: problems worth showing the
95    /// user that do not invalidate the submission. Currently
96    /// [`AnswerSheetDiagnostic::SuspectedTagInAnswer`], raised when accepted
97    /// answer text contains `<id:` anywhere — a mid-line tag mention, a
98    /// mangled tag, or a half-deleted one. Empty for interactive collection.
99    pub fn warnings(&self) -> &[AnswerSheetDiagnostic] {
100        &self.warnings
101    }
102}
103
104/// One problem found while parsing an answer sheet.
105///
106/// Diagnostics identify locations by 1-based line number and fields by
107/// stable ID or occurrence path; they never echo full answer values, since
108/// answer sheets may contain sensitive content. Compatibility diagnostics
109/// deliberately point at re-rendering: version 1 rejects incompatible sheets
110/// instead of migrating them.
111#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
112pub enum AnswerSheetDiagnostic {
113    /// The document is incompatible with the parsing definition: a missing
114    /// or malformed `#!` preamble line, an unsupported answer-format
115    /// version, or a questionnaire-ID or fingerprint mismatch. The message
116    /// points at rendering a fresh sheet; version 1 rejects instead of
117    /// migrating.
118    #[error("{message}")]
119    Incompatible {
120        /// What is incompatible, and what to do about it.
121        message: String,
122    },
123
124    /// A question or group tag line could not be accepted: its terminal
125    /// `<id:...>` tag is unknown to the schema, duplicated, or outside the
126    /// scope its definition allows.
127    #[error("Line {line}: {message}")]
128    Tag {
129        /// 1-based line number of the rejected tag line.
130        line: usize,
131        /// Which tag was rejected, and why.
132        message: String,
133    },
134
135    /// Warning: accepted answer text contains `<id:` — a mid-line tag
136    /// mention, a mangled tag, or a half-deleted one. The submission is
137    /// still accepted; a tag only structures the sheet when it ends its
138    /// line.
139    #[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.")]
140    SuspectedTagInAnswer {
141        /// The occurrence path of the answer holding the fragment.
142        path: String,
143        /// 1-based line number of the answer line containing `<id:`.
144        line: usize,
145    },
146
147    /// The answer-sheet document could not be read at all (unreadable file,
148    /// terminal stdin, or an I/O failure), so no content was parsed.
149    #[error("Could not read the answer sheet: {detail}")]
150    UnreadableDocument {
151        /// What prevented reading, without any document content.
152        detail: String,
153    },
154}
155
156impl AnswerSheetDiagnostic {
157    /// An incompatible-document diagnostic.
158    fn incompatible(message: impl Into<String>) -> Self {
159        Self::Incompatible {
160            message: message.into(),
161        }
162    }
163
164    /// A rejected tag line at a 1-based line number.
165    fn tag(line: usize, message: impl Into<String>) -> Self {
166        Self::Tag {
167            line,
168            message: message.into(),
169        }
170    }
171
172    /// A malformed-preamble diagnostic at a 1-based line number.
173    fn malformed_preamble(line: usize, detail: impl std::fmt::Display) -> Self {
174        Self::incompatible(format!(
175            "Line {line}: malformed answer-sheet preamble: {detail}. Render a fresh answer sheet and copy your answers into it."
176        ))
177    }
178}
179
180/// The field (or discard sink) currently accumulating answer lines.
181struct OpenAnswer {
182    /// `Some(path)` for a recognized field; `None` discards the answer text
183    /// of an unknown, duplicate, or misplaced question line so it cannot
184    /// leak into a neighbor.
185    path: Option<String>,
186    /// Accumulated answer lines with their 0-based document line indexes,
187    /// so tag-fragment warnings can point at the exact line.
188    lines: Vec<(usize, String)>,
189}
190
191impl OpenAnswer {
192    /// Commit this answer: trim outer whitespace, keep internal line
193    /// breaks, and raise a [`AnswerSheetDiagnostic::SuspectedTagInAnswer`]
194    /// warning for every accepted line containing `<id:`.
195    fn flush_into(
196        self,
197        values: &mut BTreeMap<String, String>,
198        warnings: &mut Vec<AnswerSheetDiagnostic>,
199    ) {
200        let Some(path) = self.path else {
201            return;
202        };
203        for (index, line) in &self.lines {
204            if line.contains(TAG_OPEN) {
205                warnings.push(AnswerSheetDiagnostic::SuspectedTagInAnswer {
206                    path: path.clone(),
207                    line: index + 1,
208                });
209            }
210        }
211        let text = self
212            .lines
213            .into_iter()
214            .map(|(_, line)| line)
215            .collect::<Vec<_>>()
216            .join("\n");
217        values.insert(path, text.trim().to_string());
218    }
219}
220
221/// One open group occurrence on the parser's scope stack.
222struct Scope {
223    /// The group's stable ID.
224    group_id: String,
225    /// The definition-ID prefix its children extend (`<group_id>.`).
226    def_prefix: String,
227    /// The occurrence path of this occurrence (`command.inputs[1]`).
228    path_prefix: String,
229    /// A scope opened after a structural diagnostic: its content parses for
230    /// boundary tracking but is discarded rather than piling on speculative
231    /// diagnostics.
232    discard: bool,
233}
234
235/// Returns the ID of the line-terminal `<id:...>` tag when `line` ends with
236/// one: the tag must be the last non-whitespace content on the line, and its
237/// ID must be shaped like a stable ID (non-empty, only `a-z`, `0-9`, `.`,
238/// `_`, `-`). Any trailing non-blank character after the tag — or a
239/// malformed ID — makes the line ordinary prose (`None`).
240fn terminal_tag(line: &str) -> Option<&str> {
241    let before_close = line.trim_end().strip_suffix('>')?;
242    let open = before_close.rfind(TAG_OPEN)?;
243    let id = &before_close[open + TAG_OPEN.len()..];
244    let valid = !id.is_empty()
245        && id
246            .chars()
247            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-'));
248    valid.then_some(id)
249}
250
251impl Questionnaire {
252    /// Parse an edited answer sheet back into [`RawAnswers`].
253    ///
254    /// The document must have been rendered by
255    /// [`render_answer_sheet`](Self::render_answer_sheet) for this exact
256    /// definition: the preamble's answer-format version, questionnaire ID,
257    /// and fingerprint are checked exactly, and any mismatch returns
258    /// diagnostics asking for a fresh sheet without reading the body.
259    ///
260    /// The body parses in one linear pass under a single recognition rule:
261    /// a line is a question line if and only if it ends with a `<id:...>`
262    /// tag as its last non-whitespace content; any trailing non-blank
263    /// character demotes the line to prose. A field's answer is everything
264    /// between its question line and the next question line (or end of
265    /// file), outer whitespace trimmed, internal line breaks preserved. A
266    /// group tag line opens one group occurrence — repeated items come from
267    /// repeated tag lines, so copying a complete rendered group block
268    /// submits one more occurrence, whatever its display numbers say.
269    /// Bracketed prose, `->` bullets, and mid-line tag mentions inside an
270    /// answer are inert answer text (a mid-line `<id:` raises a
271    /// [warning](RawAnswers::warnings)). The accepted trade-off: an answer
272    /// line that itself *ends* with a schema-valid tag is read as a
273    /// question line; there is no escaping mechanism.
274    ///
275    /// # Errors
276    ///
277    /// Returns every accumulated [`AnswerSheetDiagnostic`]: compatibility
278    /// mismatches, malformed preambles, and unknown, duplicate, or
279    /// misplaced tags on question lines. Occurrence counts *below* a
280    /// repeatable group's minimum (or above its maximum) are not parse
281    /// errors — they are structural validation, reported with the other
282    /// value diagnostics by [`decode_answers`](Self::decode_answers).
283    pub fn parse_answer_sheet(&self, text: &str) -> Result<RawAnswers, Vec<AnswerSheetDiagnostic>> {
284        let lines: Vec<&str> = text.lines().collect();
285        let body_start = self.check_preamble(&lines)?;
286
287        let mut diagnostics: Vec<AnswerSheetDiagnostic> = Vec::new();
288        let mut warnings: Vec<AnswerSheetDiagnostic> = Vec::new();
289        let mut values: BTreeMap<String, String> = BTreeMap::new();
290        let mut occurrences: BTreeMap<String, usize> = BTreeMap::new();
291        let mut seen_sections: HashSet<String> = HashSet::new();
292        let mut stack: Vec<Scope> = Vec::new();
293        let mut open: Option<OpenAnswer> = None;
294
295        for (index, line) in lines.iter().enumerate().skip(body_start) {
296            let Some(id) = terminal_tag(line) else {
297                // Ordinary content: part of the open answer, or ignored
298                // prose between a group tag line and its first question.
299                if let Some(current) = open.as_mut() {
300                    current.lines.push((index, line.to_string()));
301                }
302                continue;
303            };
304
305            if let Some(previous) = open.take() {
306                previous.flush_into(&mut values, &mut warnings);
307            }
308            let is_group = self.node_meta(id).is_some_and(|meta| meta.group);
309            if is_group {
310                self.open_group(
311                    id,
312                    index,
313                    &mut stack,
314                    &mut occurrences,
315                    &mut seen_sections,
316                    &mut diagnostics,
317                );
318            } else {
319                let path = self.open_field(id, index, &mut stack, &values, &mut diagnostics);
320                open = Some(OpenAnswer {
321                    path,
322                    lines: Vec::new(),
323                });
324            }
325        }
326        if let Some(last) = open {
327            last.flush_into(&mut values, &mut warnings);
328        }
329
330        if diagnostics.is_empty() {
331            Ok(RawAnswers {
332                values,
333                occurrences,
334                warnings,
335            })
336        } else {
337            Err(diagnostics)
338        }
339    }
340
341    /// Recognize one field question line: resolve its scope (popping closed
342    /// groups), then return the occurrence path to accumulate its answer
343    /// under — or `None` (a discard sink) with the appropriate diagnostic.
344    /// Also handles unknown tags: the schema knows no node for the ID.
345    fn open_field(
346        &self,
347        id: &str,
348        line_index: usize,
349        stack: &mut Vec<Scope>,
350        values: &BTreeMap<String, String>,
351        diagnostics: &mut Vec<AnswerSheetDiagnostic>,
352    ) -> Option<String> {
353        let line = line_index + 1;
354        let Some(meta) = self.node_meta(id) else {
355            diagnostics.push(AnswerSheetDiagnostic::tag(
356                line,
357                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."),
358            ));
359            return None;
360        };
361        let Some(keep) = resolve_scope(stack, meta.parent.as_deref()) else {
362            diagnostics.push(AnswerSheetDiagnostic::tag(
363                line,
364                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."),
365            ));
366            return None;
367        };
368        stack.truncate(keep);
369        let (def_prefix, path_prefix, discard) = match stack.last() {
370            Some(scope) => (
371                scope.def_prefix.as_str(),
372                scope.path_prefix.as_str(),
373                scope.discard,
374            ),
375            None => ("", "", false),
376        };
377        if discard {
378            return None;
379        }
380        let path = path_join(path_prefix, child_segment(def_prefix, id));
381        if values.contains_key(&path) {
382            diagnostics.push(AnswerSheetDiagnostic::tag(
383                line,
384                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."),
385            ));
386            return None;
387        }
388        Some(path)
389    }
390
391    /// Recognize one group tag line: resolve its scope, count the
392    /// occurrence, and push the occurrence scope (a discard scope after a
393    /// misplacement or duplicate, so nested content does not cascade
394    /// diagnostics).
395    fn open_group(
396        &self,
397        id: &str,
398        line_index: usize,
399        stack: &mut Vec<Scope>,
400        occurrences: &mut BTreeMap<String, usize>,
401        seen_sections: &mut HashSet<String>,
402        diagnostics: &mut Vec<AnswerSheetDiagnostic>,
403    ) {
404        let line = line_index + 1;
405        let group = self
406            .group_def(id)
407            .expect("caller verified the ID names a group");
408        let parent = self
409            .node_meta(id)
410            .expect("known group has meta")
411            .parent
412            .clone();
413
414        let discard_scope = |discard: bool| Scope {
415            group_id: group.id().to_string(),
416            def_prefix: group.def_prefix(),
417            path_prefix: String::new(),
418            discard,
419        };
420
421        let Some(keep) = resolve_scope(stack, parent.as_deref()) else {
422            diagnostics.push(AnswerSheetDiagnostic::tag(
423                line,
424                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."),
425            ));
426            stack.push(discard_scope(true));
427            return;
428        };
429        stack.truncate(keep);
430        let (parent_def, parent_path, parent_discard) = match stack.last() {
431            Some(scope) => (
432                scope.def_prefix.as_str(),
433                scope.path_prefix.as_str(),
434                scope.discard,
435            ),
436            None => ("", "", false),
437        };
438        if parent_discard {
439            stack.push(discard_scope(true));
440            return;
441        }
442        let base = path_join(parent_path, child_segment(parent_def, id));
443        let path_prefix = match group.repeat() {
444            Some(_) => {
445                let count = occurrences.entry(base.clone()).or_insert(0);
446                let index = *count;
447                *count += 1;
448                format!("{base}[{index}]")
449            }
450            None => {
451                if !seen_sections.insert(base.clone()) {
452                    diagnostics.push(AnswerSheetDiagnostic::tag(
453                        line,
454                        format!("duplicate group '<id:{id}>'. This group is answered once; remove the extra block (only repeatable sections take copied blocks)."),
455                    ));
456                    stack.push(discard_scope(true));
457                    return;
458                }
459                base
460            }
461        };
462        stack.push(Scope {
463            group_id: group.id().to_string(),
464            def_prefix: group.def_prefix(),
465            path_prefix,
466            discard: false,
467        });
468    }
469
470    /// Validate the three-line `#!` preamble against this definition.
471    ///
472    /// Returns the index of the first body line, or every compatibility and
473    /// shape diagnostic found. Blank lines before and between preamble lines
474    /// are tolerated; the preamble content itself is matched exactly.
475    fn check_preamble(&self, lines: &[&str]) -> Result<usize, Vec<AnswerSheetDiagnostic>> {
476        let mut diagnostics = Vec::new();
477        let mut i = 0;
478
479        let next_content = |i: &mut usize| -> Option<usize> {
480            while *i < lines.len() && lines[*i].trim().is_empty() {
481                *i += 1;
482            }
483            (*i < lines.len()).then(|| {
484                let at = *i;
485                *i += 1;
486                at
487            })
488        };
489
490        match next_content(&mut i) {
491            Some(at) => {
492                let line = lines[at].trim();
493                if line != FORMAT_LINE {
494                    match line.strip_prefix("#! standout-answers ") {
495                        Some(version) => {
496                            diagnostics.push(AnswerSheetDiagnostic::incompatible(format!(
497                                "Unsupported answer-format version '{}' (this release reads only version 1). Render a fresh answer sheet; old sheets are not migrated.",
498                                version.trim()
499                            )))
500                        }
501                        None => diagnostics.push(AnswerSheetDiagnostic::malformed_preamble(
502                            at + 1,
503                            format!("expected '{FORMAT_LINE}'"),
504                        )),
505                    }
506                }
507            }
508            None => diagnostics.push(AnswerSheetDiagnostic::malformed_preamble(
509                lines.len() + 1,
510                format!("expected '{FORMAT_LINE}'"),
511            )),
512        }
513
514        let expect_keyed = |i: &mut usize,
515                            prefix: &str,
516                            diagnostics: &mut Vec<AnswerSheetDiagnostic>|
517         -> Option<String> {
518            match next_content(i) {
519                Some(at) => match lines[at].trim().strip_prefix(prefix) {
520                    Some(value) => Some(value.trim().to_string()),
521                    None => {
522                        diagnostics.push(AnswerSheetDiagnostic::malformed_preamble(
523                            at + 1,
524                            format!("expected '{prefix} ...'"),
525                        ));
526                        None
527                    }
528                },
529                None => {
530                    diagnostics.push(AnswerSheetDiagnostic::malformed_preamble(
531                        lines.len() + 1,
532                        format!("expected '{prefix} ...'"),
533                    ));
534                    None
535                }
536            }
537        };
538
539        if let Some(found) = expect_keyed(&mut i, QUESTIONNAIRE_PREFIX, &mut diagnostics) {
540            if found != self.id() {
541                diagnostics.push(AnswerSheetDiagnostic::incompatible(format!(
542                    "This answer sheet is for questionnaire '{found}', not '{expected}'. Render a fresh answer sheet for '{expected}'.",
543                    expected = self.id()
544                )));
545            }
546        }
547        if let Some(found) = expect_keyed(&mut i, FINGERPRINT_PREFIX, &mut diagnostics) {
548            if found != self.fingerprint() {
549                diagnostics.push(AnswerSheetDiagnostic::incompatible(format!(
550                    "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.",
551                    expected = self.fingerprint()
552                )));
553            }
554        }
555
556        if diagnostics.is_empty() {
557            Ok(i)
558        } else {
559            Err(diagnostics)
560        }
561    }
562}
563
564/// Find the stack depth to keep so the top of the stack is the scope for
565/// `parent` (`None` = the questionnaire root): closed sibling groups pop,
566/// while an ID whose parent is not on the stack at all is misplaced
567/// (`None`).
568fn resolve_scope(stack: &[Scope], parent: Option<&str>) -> Option<usize> {
569    match parent {
570        None => Some(0),
571        Some(parent) => stack
572            .iter()
573            .rposition(|scope| scope.group_id == parent)
574            .map(|found| found + 1),
575    }
576}