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 Committed,
59 NotRecorded,
61}
62
63impl Source {
64 #[must_use]
66 pub const fn label(self) -> &'static str {
67 match self {
68 Self::Signed => "signed",
69 Self::Committed => "committed",
70 Self::NotRecorded => "not recorded",
71 }
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
77pub struct TourStep {
78 pub kind: StepKind,
80 pub source: Source,
82 pub title: String,
84 pub body_markdown: String,
86 pub facts: serde_json::Value,
88}
89
90#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
92pub struct EvidenceTour {
93 pub feature: String,
95 pub task: String,
97 pub title: String,
99 pub verdict: Option<String>,
101 pub commit: Option<String>,
103 pub steps: Vec<TourStep>,
105 pub unverifiable: Option<String>,
107}
108
109impl EvidenceTour {
110 #[must_use]
113 pub fn markdown(&self, verdict_words: &str) -> String {
114 let mut out = format!(
115 "<details><summary><b>{}</b> {} — {}</summary>\n\n",
116 self.task,
117 escape(&self.title),
118 verdict_words
119 );
120 for (i, step) in self.steps.iter().enumerate() {
121 let mut lines = step.body_markdown.lines();
122 out.push_str(&format!(
123 "{}. **{}** · {} — {}\n",
124 i + 1,
125 step.title,
126 step.source.label(),
127 lines.next().unwrap_or_default()
128 ));
129 for line in lines {
130 if line.is_empty() {
131 out.push('\n');
132 } else {
133 out.push_str(&format!(" {line}\n"));
134 }
135 }
136 }
137 if let Some(why) = &self.unverifiable {
138 out.push_str(&format!("\nUnverifiable: {why}. The steps after the requirement are not shown: nothing verifiable backs them.\n"));
139 }
140 out.push_str("</details>\n");
141 out
142 }
143}
144
145#[must_use]
147pub fn escape(text: &str) -> String {
148 text.replace('&', "&")
149 .replace('<', "<")
150 .replace('>', ">")
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn markdown_numbers_the_steps_with_their_sources_and_indents_blocks() {
159 let tour = EvidenceTour {
160 feature: "001-x".to_owned(),
161 task: "T012".to_owned(),
162 title: "Parse <header>".to_owned(),
163 verdict: Some("verified".to_owned()),
164 commit: Some("abc".to_owned()),
165 steps: vec![
166 TourStep {
167 kind: StepKind::Requirement,
168 source: Source::Signed,
169 title: "Requirement".to_owned(),
170 body_markdown: "FR-004\n> The parser MUST reject it.".to_owned(),
171 facts: serde_json::Value::Null,
172 },
173 TourStep {
174 kind: StepKind::Red,
175 source: Source::NotRecorded,
176 title: "Red".to_owned(),
177 body_markdown: "no red phase".to_owned(),
178 facts: serde_json::Value::Null,
179 },
180 ],
181 unverifiable: None,
182 };
183 let md = tour.markdown("VERIFIED");
184 assert!(
185 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"),
186 "{md}"
187 );
188 assert!(md.ends_with("</details>\n"));
189 }
190}