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}