Skip to main content

speccoder_core/
tour.rs

1//! The evidence tour: one task's evidence as six steps in a fixed order —
2//! requirement, frozen tests, red, diff, green, strength — each saying
3//! where it comes from. The payload `evidence_tour`, and its Markdown (the
4//! check run, the pull-request body); the app renders the same JSON.
5
6use serde::Serialize;
7
8/// A step of the tour, in its order.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
10#[serde(rename_all = "snake_case")]
11pub enum StepKind {
12    /// The requirement passage the task cites, as the tick's commit has it.
13    Requirement,
14    /// The acceptance tests frozen before the attempt.
15    FrozenTests,
16    /// The tests failing first, and how.
17    Red,
18    /// The task commit's diff.
19    Diff,
20    /// What passed after: acceptance, held-out, the regression checks.
21    Green,
22    /// How well the tests tell right from wrong.
23    Strength,
24}
25
26impl StepKind {
27    /// The six, in order.
28    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    /// The step's title.
38    #[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/// Where a step's content comes from.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
53#[serde(rename_all = "snake_case")]
54pub enum Source {
55    /// A signed statement says it (and what is shown hashes to it).
56    Signed,
57    /// The commit holds it (git objects, not a signature).
58    Committed,
59    /// Nothing recorded it.
60    NotRecorded,
61}
62
63impl Source {
64    /// The label shown beside the step.
65    #[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/// One step.
76#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
77pub struct TourStep {
78    /// Which.
79    pub kind: StepKind,
80    /// Where it comes from.
81    pub source: Source,
82    /// Its title (`Requirement`, `Frozen tests`, …).
83    pub title: String,
84    /// What it shows, Markdown: a first line, then optional block lines.
85    pub body_markdown: String,
86    /// The facts shown, as data (names, counts, digests).
87    pub facts: serde_json::Value,
88}
89
90/// Result of `speccoder-verify tour` and `/api/tour`: one task's tour.
91#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
92pub struct EvidenceTour {
93    /// The unit.
94    pub feature: String,
95    /// Task ID.
96    pub task: String,
97    /// The task's title.
98    pub title: String,
99    /// The verdict the evidence claims, when any.
100    pub verdict: Option<String>,
101    /// The task commit, when one carries the task's evidence.
102    pub commit: Option<String>,
103    /// The six steps, in order — only the first for an unverifiable tick.
104    pub steps: Vec<TourStep>,
105    /// For an unverifiable tick: why the evidence does not count.
106    pub unverifiable: Option<String>,
107}
108
109impl EvidenceTour {
110    /// The tour as collapsed Markdown (`<details>`), as the check run and
111    /// the pull-request body show it.
112    #[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/// `<`, `>` and `&` as entities: a title is text, never markup.
146#[must_use]
147pub fn escape(text: &str) -> String {
148    text.replace('&', "&amp;")
149        .replace('<', "&lt;")
150        .replace('>', "&gt;")
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 &lt;header&gt; — 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}