1use serde::Serialize;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
10#[serde(rename_all = "snake_case")]
11pub enum StepKind {
12 Requirement,
14 FrozenTests,
16 Red,
18 Diff,
20 Green,
22 Strength,
24}
25
26impl StepKind {
27 pub const ALL: [Self; 6] = [
29 Self::Requirement,
30 Self::FrozenTests,
31 Self::Red,
32 Self::Diff,
33 Self::Green,
34 Self::Strength,
35 ];
36
37 #[must_use]
39 pub const fn title(self) -> &'static str {
40 match self {
41 Self::Requirement => "Requirement",
42 Self::FrozenTests => "Frozen tests",
43 Self::Red => "Red",
44 Self::Diff => "Diff",
45 Self::Green => "Green",
46 Self::Strength => "Strength",
47 }
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
53#[serde(rename_all = "snake_case")]
54pub enum Source {
55 Signed,
57 Recorded,
59 Committed,
61 NotRecorded,
63}
64
65impl Source {
66 #[must_use]
68 pub const fn label(self) -> &'static str {
69 match self {
70 Self::Signed => "signed",
71 Self::Recorded => "recorded",
72 Self::Committed => "committed",
73 Self::NotRecorded => "not recorded",
74 }
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
80pub struct TourStep {
81 pub kind: StepKind,
83 pub source: Source,
85 pub title: String,
87 pub body_markdown: String,
89 pub facts: serde_json::Value,
91}
92
93#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
95pub struct EvidenceTour {
96 pub feature: String,
98 pub task: String,
100 pub title: String,
102 pub verdict: Option<String>,
104 pub commit: Option<String>,
106 pub steps: Vec<TourStep>,
108 pub unverifiable: Option<String>,
110}
111
112impl EvidenceTour {
113 #[must_use]
116 pub fn markdown(&self, verdict_words: &str) -> String {
117 let mut out = format!(
118 "<details><summary><b>{}</b> {} — {}</summary>\n\n",
119 self.task,
120 escape(&self.title),
121 verdict_words
122 );
123 for (i, step) in self.steps.iter().enumerate() {
124 let mut lines = step.body_markdown.lines();
125 out.push_str(&format!(
126 "{}. **{}** · {} — {}\n",
127 i + 1,
128 step.title,
129 step.source.label(),
130 lines.next().unwrap_or_default()
131 ));
132 for line in lines {
133 if line.is_empty() {
134 out.push('\n');
135 } else {
136 out.push_str(&format!(" {line}\n"));
137 }
138 }
139 }
140 if let Some(why) = &self.unverifiable {
141 out.push_str(&format!("\nUnverifiable: {why}. The steps after the requirement are not shown: nothing verifiable backs them.\n"));
142 }
143 out.push_str("</details>\n");
144 out
145 }
146}
147
148#[must_use]
150pub fn escape(text: &str) -> String {
151 text.replace('&', "&")
152 .replace('<', "<")
153 .replace('>', ">")
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn markdown_numbers_the_steps_with_their_sources_and_indents_blocks() {
162 let tour = EvidenceTour {
163 feature: "001-x".to_owned(),
164 task: "T012".to_owned(),
165 title: "Parse <header>".to_owned(),
166 verdict: Some("verified".to_owned()),
167 commit: Some("abc".to_owned()),
168 steps: vec![
169 TourStep {
170 kind: StepKind::Requirement,
171 source: Source::Signed,
172 title: "Requirement".to_owned(),
173 body_markdown: "FR-004\n> The parser MUST reject it.".to_owned(),
174 facts: serde_json::Value::Null,
175 },
176 TourStep {
177 kind: StepKind::Red,
178 source: Source::NotRecorded,
179 title: "Red".to_owned(),
180 body_markdown: "no red phase".to_owned(),
181 facts: serde_json::Value::Null,
182 },
183 ],
184 unverifiable: None,
185 };
186 let md = tour.markdown("VERIFIED");
187 assert!(
188 md.starts_with("<details><summary><b>T012</b> Parse <header> — VERIFIED</summary>\n\n1. **Requirement** · signed — FR-004\n > The parser MUST reject it.\n2. **Red** · not recorded — no red phase\n"),
189 "{md}"
190 );
191 assert!(md.ends_with("</details>\n"));
192 }
193}