standout-input 9.0.0

Declarative input collection for CLI applications
Documentation
use std::collections::{BTreeMap, HashSet};

use super::definition::{child_segment, path_join, Questionnaire};
use super::render::{FINGERPRINT_PREFIX, FORMAT_LINE, QUESTIONNAIRE_PREFIX, TAG_OPEN};

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RawAnswers {
    values: BTreeMap<String, String>,
    occurrences: BTreeMap<String, usize>,
    warnings: Vec<AnswerSheetDiagnostic>,
}

impl RawAnswers {
    #[cfg(feature = "simple-prompts")]
    pub(crate) fn from_parts(
        values: BTreeMap<String, String>,
        occurrences: BTreeMap<String, usize>,
    ) -> Self {
        Self {
            values,
            occurrences,
            warnings: Vec::new(),
        }
    }

    pub fn get(&self, path: &str) -> Option<&str> {
        self.values.get(path).map(String::as_str)
    }

    pub fn occurrence_count(&self, group_path: &str) -> usize {
        self.occurrences.get(group_path).copied().unwrap_or(0)
    }

    pub fn warnings(&self) -> &[AnswerSheetDiagnostic] {
        &self.warnings
    }
}

#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub enum AnswerSheetDiagnostic {
    #[error("{message}")]
    Incompatible { message: String },

    #[error("Line {line}: {message}")]
    Tag { line: usize, message: String },

    #[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.")]
    SuspectedTagInAnswer { path: String, line: usize },

    #[error("Could not read the answer sheet: {detail}")]
    UnreadableDocument { detail: String },
}

impl AnswerSheetDiagnostic {
    fn incompatible(message: impl Into<String>) -> Self {
        Self::Incompatible {
            message: message.into(),
        }
    }

    fn tag(line: usize, message: impl Into<String>) -> Self {
        Self::Tag {
            line,
            message: message.into(),
        }
    }

    fn malformed_preamble(line: usize, detail: impl std::fmt::Display) -> Self {
        Self::incompatible(format!(
            "Line {line}: malformed answer-sheet preamble: {detail}. Render a fresh answer sheet and copy your answers into it."
        ))
    }
}

struct OpenAnswer {
    path: Option<String>,
    lines: Vec<(usize, String)>,
}

impl OpenAnswer {
    fn flush_into(
        self,
        values: &mut BTreeMap<String, String>,
        warnings: &mut Vec<AnswerSheetDiagnostic>,
    ) {
        let Some(path) = self.path else {
            return;
        };
        for (index, line) in &self.lines {
            if line.contains(TAG_OPEN) {
                warnings.push(AnswerSheetDiagnostic::SuspectedTagInAnswer {
                    path: path.clone(),
                    line: index + 1,
                });
            }
        }
        let text = self
            .lines
            .into_iter()
            .map(|(_, line)| line)
            .collect::<Vec<_>>()
            .join("\n");
        values.insert(path, text.trim().to_string());
    }
}

struct Scope {
    group_id: String,
    def_prefix: String,
    path_prefix: String,
    discard: bool,
}

fn terminal_tag(line: &str) -> Option<&str> {
    let before_close = line.trim_end().strip_suffix('>')?;
    let open = before_close.rfind(TAG_OPEN)?;
    let id = &before_close[open + TAG_OPEN.len()..];
    let valid = !id.is_empty()
        && id
            .chars()
            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-'));
    valid.then_some(id)
}

impl Questionnaire {
    pub fn parse_answer_sheet(&self, text: &str) -> Result<RawAnswers, Vec<AnswerSheetDiagnostic>> {
        let lines: Vec<&str> = text.lines().collect();
        let body_start = self.check_preamble(&lines)?;

        let mut diagnostics: Vec<AnswerSheetDiagnostic> = Vec::new();
        let mut warnings: Vec<AnswerSheetDiagnostic> = Vec::new();
        let mut values: BTreeMap<String, String> = BTreeMap::new();
        let mut occurrences: BTreeMap<String, usize> = BTreeMap::new();
        let mut seen_sections: HashSet<String> = HashSet::new();
        let mut stack: Vec<Scope> = Vec::new();
        let mut open: Option<OpenAnswer> = None;

        for (index, line) in lines.iter().enumerate().skip(body_start) {
            let Some(id) = terminal_tag(line) else {
                if let Some(current) = open.as_mut() {
                    current.lines.push((index, line.to_string()));
                }
                continue;
            };

            if let Some(previous) = open.take() {
                previous.flush_into(&mut values, &mut warnings);
            }
            let is_group = self.node_meta(id).is_some_and(|meta| meta.group);
            if is_group {
                self.open_group(
                    id,
                    index,
                    &mut stack,
                    &mut occurrences,
                    &mut seen_sections,
                    &mut diagnostics,
                );
            } else {
                let path = self.open_field(id, index, &mut stack, &values, &mut diagnostics);
                open = Some(OpenAnswer {
                    path,
                    lines: Vec::new(),
                });
            }
        }
        if let Some(last) = open {
            last.flush_into(&mut values, &mut warnings);
        }

        if diagnostics.is_empty() {
            Ok(RawAnswers {
                values,
                occurrences,
                warnings,
            })
        } else {
            Err(diagnostics)
        }
    }

    fn open_field(
        &self,
        id: &str,
        line_index: usize,
        stack: &mut Vec<Scope>,
        values: &BTreeMap<String, String>,
        diagnostics: &mut Vec<AnswerSheetDiagnostic>,
    ) -> Option<String> {
        let line = line_index + 1;
        let Some(meta) = self.node_meta(id) else {
            diagnostics.push(AnswerSheetDiagnostic::tag(
                line,
                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."),
            ));
            return None;
        };
        let Some(keep) = resolve_scope(stack, meta.parent.as_deref()) else {
            diagnostics.push(AnswerSheetDiagnostic::tag(
                line,
                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."),
            ));
            return None;
        };
        stack.truncate(keep);
        let (def_prefix, path_prefix, discard) = match stack.last() {
            Some(scope) => (
                scope.def_prefix.as_str(),
                scope.path_prefix.as_str(),
                scope.discard,
            ),
            None => ("", "", false),
        };
        if discard {
            return None;
        }
        let path = path_join(path_prefix, child_segment(def_prefix, id));
        if values.contains_key(&path) {
            diagnostics.push(AnswerSheetDiagnostic::tag(
                line,
                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."),
            ));
            return None;
        }
        Some(path)
    }

    fn open_group(
        &self,
        id: &str,
        line_index: usize,
        stack: &mut Vec<Scope>,
        occurrences: &mut BTreeMap<String, usize>,
        seen_sections: &mut HashSet<String>,
        diagnostics: &mut Vec<AnswerSheetDiagnostic>,
    ) {
        let line = line_index + 1;
        let group = self
            .group_def(id)
            .expect("caller verified the ID names a group");
        let parent = self
            .node_meta(id)
            .expect("known group has meta")
            .parent
            .clone();

        let discard_scope = |discard: bool| Scope {
            group_id: group.id().to_string(),
            def_prefix: group.def_prefix(),
            path_prefix: String::new(),
            discard,
        };

        let Some(keep) = resolve_scope(stack, parent.as_deref()) else {
            diagnostics.push(AnswerSheetDiagnostic::tag(
                line,
                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."),
            ));
            stack.push(discard_scope(true));
            return;
        };
        stack.truncate(keep);
        let (parent_def, parent_path, parent_discard) = match stack.last() {
            Some(scope) => (
                scope.def_prefix.as_str(),
                scope.path_prefix.as_str(),
                scope.discard,
            ),
            None => ("", "", false),
        };
        if parent_discard {
            stack.push(discard_scope(true));
            return;
        }
        let base = path_join(parent_path, child_segment(parent_def, id));
        let path_prefix = match group.repeat() {
            Some(_) => {
                let count = occurrences.entry(base.clone()).or_insert(0);
                let index = *count;
                *count += 1;
                format!("{base}[{index}]")
            }
            None => {
                if !seen_sections.insert(base.clone()) {
                    diagnostics.push(AnswerSheetDiagnostic::tag(
                        line,
                        format!("duplicate group '<id:{id}>'. This group is answered once; remove the extra block (only repeatable sections take copied blocks)."),
                    ));
                    stack.push(discard_scope(true));
                    return;
                }
                base
            }
        };
        stack.push(Scope {
            group_id: group.id().to_string(),
            def_prefix: group.def_prefix(),
            path_prefix,
            discard: false,
        });
    }

    fn check_preamble(&self, lines: &[&str]) -> Result<usize, Vec<AnswerSheetDiagnostic>> {
        let mut diagnostics = Vec::new();
        let mut i = 0;

        let next_content = |i: &mut usize| -> Option<usize> {
            while *i < lines.len() && lines[*i].trim().is_empty() {
                *i += 1;
            }
            (*i < lines.len()).then(|| {
                let at = *i;
                *i += 1;
                at
            })
        };

        match next_content(&mut i) {
            Some(at) => {
                let line = lines[at].trim();
                if line != FORMAT_LINE {
                    match line.strip_prefix("#! standout-answers ") {
                        Some(version) => {
                            diagnostics.push(AnswerSheetDiagnostic::incompatible(format!(
                                "Unsupported answer-format version '{}' (this release reads only version 1). Render a fresh answer sheet; old sheets are not migrated.",
                                version.trim()
                            )))
                        }
                        None => diagnostics.push(AnswerSheetDiagnostic::malformed_preamble(
                            at + 1,
                            format!("expected '{FORMAT_LINE}'"),
                        )),
                    }
                }
            }
            None => diagnostics.push(AnswerSheetDiagnostic::malformed_preamble(
                lines.len() + 1,
                format!("expected '{FORMAT_LINE}'"),
            )),
        }

        let expect_keyed = |i: &mut usize,
                            prefix: &str,
                            diagnostics: &mut Vec<AnswerSheetDiagnostic>|
         -> Option<String> {
            match next_content(i) {
                Some(at) => match lines[at].trim().strip_prefix(prefix) {
                    Some(value) => Some(value.trim().to_string()),
                    None => {
                        diagnostics.push(AnswerSheetDiagnostic::malformed_preamble(
                            at + 1,
                            format!("expected '{prefix} ...'"),
                        ));
                        None
                    }
                },
                None => {
                    diagnostics.push(AnswerSheetDiagnostic::malformed_preamble(
                        lines.len() + 1,
                        format!("expected '{prefix} ...'"),
                    ));
                    None
                }
            }
        };

        if let Some(found) = expect_keyed(&mut i, QUESTIONNAIRE_PREFIX, &mut diagnostics) {
            if found != self.id() {
                diagnostics.push(AnswerSheetDiagnostic::incompatible(format!(
                    "This answer sheet is for questionnaire '{found}', not '{expected}'. Render a fresh answer sheet for '{expected}'.",
                    expected = self.id()
                )));
            }
        }
        if let Some(found) = expect_keyed(&mut i, FINGERPRINT_PREFIX, &mut diagnostics) {
            if found != self.fingerprint() {
                diagnostics.push(AnswerSheetDiagnostic::incompatible(format!(
                    "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.",
                    expected = self.fingerprint()
                )));
            }
        }

        if diagnostics.is_empty() {
            Ok(i)
        } else {
            Err(diagnostics)
        }
    }
}

fn resolve_scope(stack: &[Scope], parent: Option<&str>) -> Option<usize> {
    match parent {
        None => Some(0),
        Some(parent) => stack
            .iter()
            .rposition(|scope| scope.group_id == parent)
            .map(|found| found + 1),
    }
}