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
51pub trait AnswerSheetFormat {
53 fn parse(
54 &self,
55 questionnaire: &Questionnaire,
56 text: &str,
57 ) -> Result<RawAnswers, Vec<AnswerSheetDiagnostic>>;
58}
59
60pub 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}