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    /// A statement says it, unsigned (no signing key was configured).
58    Recorded,
59    /// The commit holds it (git objects, not a signature).
60    Committed,
61    /// Nothing recorded it.
62    NotRecorded,
63}
64
65impl Source {
66    /// The label shown beside the step.
67    #[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/// One step.
79#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
80pub struct TourStep {
81    /// Which.
82    pub kind: StepKind,
83    /// Where it comes from.
84    pub source: Source,
85    /// Its title (`Requirement`, `Frozen tests`, …).
86    pub title: String,
87    /// What it shows, Markdown: a first line, then optional block lines.
88    pub body_markdown: String,
89    /// The facts shown, as data (names, counts, digests).
90    pub facts: serde_json::Value,
91}
92
93/// Result of `speccoder attest tour` and `/api/tour`: one task's tour.
94#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
95pub struct EvidenceTour {
96    /// The unit.
97    pub feature: String,
98    /// Task ID.
99    pub task: String,
100    /// The task's title.
101    pub title: String,
102    /// The verdict the evidence claims, when any.
103    pub verdict: Option<String>,
104    /// The task commit, when one carries the task's evidence.
105    pub commit: Option<String>,
106    /// The six steps, in order — only the first for an unverifiable tick.
107    pub steps: Vec<TourStep>,
108    /// For an unverifiable tick: why the evidence does not count.
109    pub unverifiable: Option<String>,
110}
111
112impl EvidenceTour {
113    /// The tour as collapsed Markdown (`<details>`), as the check run and
114    /// the pull-request body show it.
115    #[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/// `<`, `>` and `&` as entities: a title is text, never markup.
149#[must_use]
150pub fn escape(text: &str) -> String {
151    text.replace('&', "&amp;")
152        .replace('<', "&lt;")
153        .replace('>', "&gt;")
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 &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"),
189            "{md}"
190        );
191        assert!(md.ends_with("</details>\n"));
192    }
193}