1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
//! The evidence tour: one task's evidence as six steps in a fixed order —
//! requirement, frozen tests, red, diff, green, strength — each saying
//! where it comes from. The payload `evidence_tour`, and its Markdown (the
//! check run, the pull-request body); the app renders the same JSON.
use serde::Serialize;
/// A step of the tour, in its order.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum StepKind {
/// The requirement passage the task cites, as the tick's commit has it.
Requirement,
/// The acceptance tests frozen before the attempt.
FrozenTests,
/// The tests failing first, and how.
Red,
/// The task commit's diff.
Diff,
/// What passed after: acceptance, held-out, the regression checks.
Green,
/// How well the tests tell right from wrong.
Strength,
}
impl StepKind {
/// The six, in order.
pub const ALL: [Self; 6] = [
Self::Requirement,
Self::FrozenTests,
Self::Red,
Self::Diff,
Self::Green,
Self::Strength,
];
/// The step's title.
#[must_use]
pub const fn title(self) -> &'static str {
match self {
Self::Requirement => "Requirement",
Self::FrozenTests => "Frozen tests",
Self::Red => "Red",
Self::Diff => "Diff",
Self::Green => "Green",
Self::Strength => "Strength",
}
}
}
/// Where a step's content comes from.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Source {
/// A signed statement says it (and what is shown hashes to it).
Signed,
/// The commit holds it (git objects, not a signature).
Committed,
/// Nothing recorded it.
NotRecorded,
}
impl Source {
/// The label shown beside the step.
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Signed => "signed",
Self::Committed => "committed",
Self::NotRecorded => "not recorded",
}
}
}
/// One step.
#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
pub struct TourStep {
/// Which.
pub kind: StepKind,
/// Where it comes from.
pub source: Source,
/// Its title (`Requirement`, `Frozen tests`, …).
pub title: String,
/// What it shows, Markdown: a first line, then optional block lines.
pub body_markdown: String,
/// The facts shown, as data (names, counts, digests).
pub facts: serde_json::Value,
}
/// Result of `speccoder attest tour` and `/api/tour`: one task's tour.
#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
pub struct EvidenceTour {
/// The unit.
pub feature: String,
/// Task ID.
pub task: String,
/// The task's title.
pub title: String,
/// The verdict the evidence claims, when any.
pub verdict: Option<String>,
/// The task commit, when one carries the task's evidence.
pub commit: Option<String>,
/// The six steps, in order — only the first for an unverifiable tick.
pub steps: Vec<TourStep>,
/// For an unverifiable tick: why the evidence does not count.
pub unverifiable: Option<String>,
}
impl EvidenceTour {
/// The tour as collapsed Markdown (`<details>`), as the check run and
/// the pull-request body show it.
#[must_use]
pub fn markdown(&self, verdict_words: &str) -> String {
let mut out = format!(
"<details><summary><b>{}</b> {} — {}</summary>\n\n",
self.task,
escape(&self.title),
verdict_words
);
for (i, step) in self.steps.iter().enumerate() {
let mut lines = step.body_markdown.lines();
out.push_str(&format!(
"{}. **{}** · {} — {}\n",
i + 1,
step.title,
step.source.label(),
lines.next().unwrap_or_default()
));
for line in lines {
if line.is_empty() {
out.push('\n');
} else {
out.push_str(&format!(" {line}\n"));
}
}
}
if let Some(why) = &self.unverifiable {
out.push_str(&format!("\nUnverifiable: {why}. The steps after the requirement are not shown: nothing verifiable backs them.\n"));
}
out.push_str("</details>\n");
out
}
}
/// `<`, `>` and `&` as entities: a title is text, never markup.
#[must_use]
pub fn escape(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn markdown_numbers_the_steps_with_their_sources_and_indents_blocks() {
let tour = EvidenceTour {
feature: "001-x".to_owned(),
task: "T012".to_owned(),
title: "Parse <header>".to_owned(),
verdict: Some("verified".to_owned()),
commit: Some("abc".to_owned()),
steps: vec![
TourStep {
kind: StepKind::Requirement,
source: Source::Signed,
title: "Requirement".to_owned(),
body_markdown: "FR-004\n> The parser MUST reject it.".to_owned(),
facts: serde_json::Value::Null,
},
TourStep {
kind: StepKind::Red,
source: Source::NotRecorded,
title: "Red".to_owned(),
body_markdown: "no red phase".to_owned(),
facts: serde_json::Value::Null,
},
],
unverifiable: None,
};
let md = tour.markdown("VERIFIED");
assert!(
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"),
"{md}"
);
assert!(md.ends_with("</details>\n"));
}
}