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}