Skip to main content

standout_input/questionnaire/
render.rs

1//! Deterministic rendering of blank answer sheets.
2//!
3//! Rendering writes the prose document described in the
4//! [module documentation](crate::questionnaire): a three-line `#!` metadata
5//! preamble followed by one numbered question block per item. Every question
6//! line ends with its stable identity tag (`<id:project.name>`) as the last
7//! non-whitespace content on the line; a declared default renders pre-filled
8//! as the answer text on the line below its question. Groups render a
9//! heading line (ending with the group's tag) followed by their nested,
10//! dot-numbered children; a repeatable group renders exactly its declared
11//! minimum number of occurrences plus one-line guidance for copying a
12//! complete block. The same definition always renders byte-identical output.
13//!
14//! All numbering is cosmetic. Each occurrence of a repeatable group renders
15//! with the *same* display numbers — the parser counts occurrences of the
16//! stable group tag, never numbers — which also keeps every block an exact
17//! copy of its siblings, so "copy the block" needs no renumbering.
18
19use std::fmt::Write as _;
20
21use super::definition::{Item, Questionnaire};
22
23/// The exact first preamble line of a version-1 answer sheet.
24pub(crate) const FORMAT_LINE: &str = "#! standout-answers 1";
25/// Preamble key prefix for the questionnaire ID line.
26pub(crate) const QUESTIONNAIRE_PREFIX: &str = "#! questionnaire:";
27/// Preamble key prefix for the fingerprint line.
28pub(crate) const FINGERPRINT_PREFIX: &str = "#! fingerprint:";
29/// The opening delimiter of a question tag (`<id:project.name>`).
30pub(crate) const TAG_OPEN: &str = "<id:";
31
32/// The copy-the-block guidance line rendered under a repeatable group's
33/// first heading. Deliberately free of `<id:` and not ending in `>`, so it
34/// can never read as a question tag to the parser or trip the tag-fragment
35/// warning.
36const REPEAT_GUIDANCE: &str =
37    "(Add an item by copying one complete block - its heading line and its questions - below the last block, then answering the copy.)";
38
39/// The rendered question tag for one stable ID.
40fn tag(id: &str) -> String {
41    format!("{TAG_OPEN}{id}>")
42}
43
44impl Questionnaire {
45    /// Render a blank answer sheet for this questionnaire.
46    ///
47    /// The output is deterministic: rendering the same definition always
48    /// produces the same document, including the fingerprint in the preamble.
49    /// Each field renders as one question line — cosmetic display number and
50    /// wording, a parenthesized type hint, and the line-terminal
51    /// `<id:...>` tag — with the answer expected on the following lines. A
52    /// field with a declared static default renders the default pre-filled
53    /// as its answer text; every other field — including one with a
54    /// [dynamic default](super::DynamicDefault), whose value depends on
55    /// other answers a static sheet cannot see — leaves the answer area
56    /// blank. A group
57    /// renders its heading line (ending with the group's tag) and its
58    /// children with nested cosmetic numbering; a repeatable group renders
59    /// exactly its declared minimum number of occurrence blocks and concise
60    /// guidance to copy a complete block when adding an item.
61    pub fn render_answer_sheet(&self) -> String {
62        let mut out = String::new();
63        out.push_str(FORMAT_LINE);
64        out.push('\n');
65        let _ = writeln!(out, "{QUESTIONNAIRE_PREFIX} {}", self.id());
66        let _ = writeln!(out, "{FINGERPRINT_PREFIX} {}", self.fingerprint());
67        render_items(self.items(), "", &mut out);
68        out
69    }
70}
71
72/// Render one scope's items, numbering them `1.`/`2.`… at the root and
73/// `<prefix>.1`/`<prefix>.2`… when nested.
74fn render_items(items: &[Item], number_prefix: &str, out: &mut String) {
75    for (index, item) in items.iter().enumerate() {
76        let number = display_number(number_prefix, index + 1);
77        match item {
78            Item::Field(field) => {
79                out.push('\n');
80                let _ = writeln!(
81                    out,
82                    "{number} {} ({}) {}",
83                    field.prompt(),
84                    field.type_hint(),
85                    tag(field.id())
86                );
87                if let Some(default) = field.default() {
88                    let _ = writeln!(out, "{default}");
89                }
90            }
91            Item::Group(group) => {
92                let occurrences = group.repeat().map_or(1, |repeat| repeat.min());
93                for occurrence in 0..occurrences {
94                    out.push('\n');
95                    let _ = writeln!(
96                        out,
97                        "{number} {} ({}) {}",
98                        group.prompt(),
99                        group.type_hint(),
100                        tag(group.id())
101                    );
102                    if group.repeat().is_some() && occurrence == 0 {
103                        out.push_str(REPEAT_GUIDANCE);
104                        out.push('\n');
105                    }
106                    render_items(group.children(), number.trim_end_matches('.'), out);
107                }
108            }
109        }
110    }
111}
112
113/// The cosmetic display number for one item: `3.` at the root, `3.1` when
114/// nested (matching the rendered examples in the module documentation).
115fn display_number(prefix: &str, ordinal: usize) -> String {
116    if prefix.is_empty() {
117        format!("{ordinal}.")
118    } else {
119        format!("{prefix}.{ordinal}")
120    }
121}