Skip to main content

speccoder_core/
regression.rs

1//! Why a tick is regression-only: one reason from a closed set, decided by
2//! code from the run's facts, worded once, and carried in the commit's
3//! `Regression-Only:` trailer and its signed statement.
4//!
5//! The reason sits beside [`crate::verify::Verdict`], not inside it, so the
6//! verdict's statements and schemas are independent of it; a tick without a
7//! recorded reason reads [`RegressionReason::Unrecorded`].
8
9use serde::{Deserialize, Serialize};
10
11use crate::verify::RedClass;
12
13/// Why a tick is regression-only.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
15#[serde(tag = "reason", rename_all = "snake_case")]
16pub enum RegressionReason {
17    /// The task belongs to no user story.
18    NoStory,
19    /// Its story states no acceptance scenario.
20    NoScenarios {
21        /// The story.
22        story: String,
23    },
24    /// `[acceptance] generate = false`.
25    AcceptanceOff,
26    /// The test author returned no file.
27    AuthorWroteNone,
28    /// The author's answer could not be used.
29    AuthorFailed {
30        /// What speccoder refused (its own words).
31        detail: String,
32    },
33    /// The acceptance tests passed before the attempt.
34    AlreadyPassing,
35    /// They did not fail on an assertion or an absent symbol.
36    NotRed {
37        /// How the red run ended.
38        red_class: RedClass,
39    },
40    /// The acceptance command collected no test.
41    TestsNotCollected,
42    /// A planning task: design documents, judged by the checks alone.
43    PlanningWork,
44    /// Its story's checkpoint, with every scenario of the story turned green
45    /// by earlier ticks: nothing left to show.
46    StoryVerified,
47    /// A task of a story that turned none of its scenarios green and broke
48    /// none: the story goes on.
49    InProgress,
50    /// A task of a story whose test check failed before and after it and
51    /// reports no per-test results: a regression could not be told apart
52    /// from the story's own red tests.
53    RegressionUnchecked,
54    /// A tick without a recorded reason (read side only).
55    Unrecorded,
56}
57
58/// A regression-only tick's reason with its one-line wording.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
60#[schemars(rename = "RegressionOnly")]
61pub struct Regression {
62    /// The reason with its typed details.
63    pub reason: RegressionReason,
64    /// speccoder's sentence for it, the same in every interface.
65    pub text: String,
66}
67
68impl Regression {
69    /// The reason, worded.
70    #[must_use]
71    pub fn of(reason: RegressionReason) -> Self {
72        let text = wording(&reason);
73        Self { reason, text }
74    }
75}
76
77impl RegressionReason {
78    /// The reason's recorded name.
79    #[must_use]
80    pub const fn name(&self) -> &'static str {
81        match self {
82            Self::NoStory => "no_story",
83            Self::NoScenarios { .. } => "no_scenarios",
84            Self::AcceptanceOff => "acceptance_off",
85            Self::AuthorWroteNone => "author_wrote_none",
86            Self::AuthorFailed { .. } => "author_failed",
87            Self::AlreadyPassing => "already_passing",
88            Self::NotRed { .. } => "not_red",
89            Self::TestsNotCollected => "tests_not_collected",
90            Self::PlanningWork => "planning_work",
91            Self::StoryVerified => "story_verified",
92            Self::InProgress => "in_progress",
93            Self::RegressionUnchecked => "regression_unchecked",
94            Self::Unrecorded => "unrecorded",
95        }
96    }
97}
98
99/// Why the acceptance author's answer was not used: typed, so the reason
100/// is decided by code, never by reading a message.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
102#[serde(tag = "cause", rename_all = "snake_case")]
103pub enum SkipCause {
104    /// The author returned no file.
105    NoFiles,
106    /// A file was refused.
107    Refused {
108        /// The path, as the author gave it.
109        path: String,
110        /// speccoder's refusal (`is not a new test file`).
111        why: String,
112    },
113    /// The command is not one the policy allows.
114    CommandNotAllowed {
115        /// The command.
116        argv: String,
117    },
118    /// Writing a file failed.
119    WriteFailed {
120        /// The path.
121        path: String,
122    },
123    /// The layout offers no place the runner collects.
124    NoPlace,
125}
126
127/// The facts a reason is decided from.
128#[derive(Debug, Clone, PartialEq, Eq, Default)]
129pub struct RegressionFacts {
130    /// `[acceptance] generate`.
131    pub acceptance_on: bool,
132    /// The task's story (`US1`), when it has one.
133    pub story: Option<String>,
134    /// The scenarios its story states.
135    pub scenarios: usize,
136    /// Why no acceptance test was kept, when none was.
137    pub skip: Option<SkipCause>,
138    /// How the acceptance tests' red run ended.
139    pub red_class: Option<RedClass>,
140}
141
142/// The reason, by precedence (highest first): `acceptance_off`,
143/// `no_story`, `no_scenarios`, `author_failed`, `author_wrote_none`,
144/// `tests_not_collected`, `not_red`, `already_passing` — for a task judged
145/// by its own acceptance tests (a convergence gap, a quick change); a story
146/// task's reason comes from its progress on the story's oracle.
147#[must_use]
148pub fn decide(f: &RegressionFacts) -> RegressionReason {
149    if !f.acceptance_on {
150        return RegressionReason::AcceptanceOff;
151    }
152    let Some(story) = f.story.as_ref() else {
153        return RegressionReason::NoStory;
154    };
155    if f.scenarios == 0 {
156        return RegressionReason::NoScenarios {
157            story: story.clone(),
158        };
159    }
160    match &f.skip {
161        Some(SkipCause::NoFiles) => return RegressionReason::AuthorWroteNone,
162        Some(cause) => {
163            return RegressionReason::AuthorFailed {
164                detail: detail(cause),
165            };
166        }
167        None => {}
168    }
169    match f.red_class {
170        None => RegressionReason::AuthorWroteNone,
171        Some(RedClass::NoTests) => RegressionReason::TestsNotCollected,
172        Some(RedClass::Passed) => RegressionReason::AlreadyPassing,
173        Some(c) => RegressionReason::NotRed { red_class: c },
174    }
175}
176
177/// A path an author chose, as speccoder shows it: one line, no backticks,
178/// at most 120 characters, inside a code span.
179#[must_use]
180pub fn code_path(p: &str) -> String {
181    let clean: String = p
182        .chars()
183        .filter(|c| !c.is_control())
184        .map(|c| if c == '`' { '\'' } else { c })
185        .collect();
186    let clean = clean.trim_start_matches("./");
187    let cut: String = if clean.chars().count() > 120 {
188        clean.chars().take(119).chain(['…']).collect()
189    } else {
190        clean.to_owned()
191    };
192    format!("`{cut}`")
193}
194
195/// speccoder's words for a skip cause.
196#[must_use]
197pub fn detail(cause: &SkipCause) -> String {
198    match cause {
199        SkipCause::NoFiles => "the test author wrote no file".to_owned(),
200        SkipCause::Refused { path, why } if why.starts_with("changes existing lines") => {
201            format!("{why} of {}", code_path(path))
202        }
203        SkipCause::Refused { path, why } => format!("{} {why}", code_path(path)),
204        // The model's command is its own words: not repeated here.
205        SkipCause::CommandNotAllowed { .. } => {
206            "the command it gave is not one the policy allows".to_owned()
207        }
208        SkipCause::WriteFailed { path } => format!("writing {} failed", code_path(path)),
209        SkipCause::NoPlace => "no place the runner collects".to_owned(),
210    }
211}
212
213fn class_words(c: RedClass) -> &'static str {
214    match c {
215        RedClass::Assertion => "assertion",
216        RedClass::Absent => "absent symbol",
217        RedClass::BuildError => "build error",
218        RedClass::CollectionError => "collection error",
219        RedClass::Timeout => "timeout",
220        RedClass::NoTests => "no tests",
221        RedClass::Flaky => "flaky",
222        RedClass::Unclassified => "unrecognised output",
223        RedClass::Passed => "passed",
224    }
225}
226
227fn class_name(c: RedClass) -> &'static str {
228    match c {
229        RedClass::Assertion => "assertion",
230        RedClass::Absent => "absent",
231        RedClass::BuildError => "build_error",
232        RedClass::CollectionError => "collection_error",
233        RedClass::Timeout => "timeout",
234        RedClass::NoTests => "no_tests",
235        RedClass::Flaky => "flaky",
236        RedClass::Unclassified => "unclassified",
237        RedClass::Passed => "passed",
238    }
239}
240
241fn class_named(s: &str) -> Option<RedClass> {
242    [
243        RedClass::Assertion,
244        RedClass::Absent,
245        RedClass::BuildError,
246        RedClass::CollectionError,
247        RedClass::Timeout,
248        RedClass::NoTests,
249        RedClass::Flaky,
250        RedClass::Unclassified,
251        RedClass::Passed,
252    ]
253    .into_iter()
254    .find(|c| class_name(*c) == s)
255}
256
257/// The one sentence per reason, the same in every interface: the
258/// only place a reason is worded.
259#[must_use]
260pub fn wording(r: &RegressionReason) -> String {
261    match r {
262        RegressionReason::NoStory => "no user story — nothing to derive a test from".to_owned(),
263        RegressionReason::NoScenarios { story } => {
264            format!("its story {story} states no acceptance scenario")
265        }
266        RegressionReason::AcceptanceOff => "acceptance tests are off in speccoder.toml".to_owned(),
267        RegressionReason::AuthorWroteNone => "the test author wrote no test for it".to_owned(),
268        RegressionReason::AuthorFailed { detail } => {
269            format!("its acceptance tests could not be used: {detail}")
270        }
271        RegressionReason::AlreadyPassing => "already passing before the attempt".to_owned(),
272        RegressionReason::NotRed { red_class } => format!(
273            "its acceptance tests did not fail first ({})",
274            class_words(*red_class)
275        ),
276        RegressionReason::TestsNotCollected => {
277            "its acceptance tests never ran: the command collected 0 tests".to_owned()
278        }
279        RegressionReason::PlanningWork => {
280            "planning work — design documents, not code: nothing to test".to_owned()
281        }
282        RegressionReason::StoryVerified => {
283            "its story's scenarios were all turned green by earlier ticks".to_owned()
284        }
285        RegressionReason::InProgress => {
286            "turned none of its story's scenarios green and broke none: the story goes on"
287                .to_owned()
288        }
289        RegressionReason::RegressionUnchecked => {
290            "its test check failed before and after and reports no per-test results, so a regression could not be told apart from the story's own red tests — set `junit` on the check".to_owned()
291        }
292        RegressionReason::Unrecorded => {
293            "why was not recorded (ticked by an earlier speccoder)".to_owned()
294        }
295    }
296}
297
298/// The trailer's name.
299pub const TRAILER: &str = "Regression-Only";
300
301/// The trailer's value: `<reason>[ <key>=<value>]…` (contracts/cli-json.md).
302#[must_use]
303pub fn trailer(r: &RegressionReason) -> String {
304    let name = r.name();
305    match r {
306        RegressionReason::NoScenarios { story } => format!("{name} story={}", escape(story)),
307        RegressionReason::AuthorFailed { detail } => format!("{name} detail={}", escape(detail)),
308        RegressionReason::NotRed { red_class } => {
309            format!("{name} red_class={}", class_name(*red_class))
310        }
311        _ => name.to_owned(),
312    }
313}
314
315/// Reads a trailer value back; `None` when it is not one speccoder writes.
316#[must_use]
317pub fn parse_trailer(value: &str) -> Option<RegressionReason> {
318    let mut parts = value.split_whitespace();
319    let name = parts.next()?;
320    let mut kv = std::collections::BTreeMap::new();
321    for p in parts {
322        let (k, v) = p.split_once('=')?;
323        kv.insert(k, unescape(v)?);
324    }
325    let get = |k: &str| kv.get(k).cloned();
326    Some(match name {
327        "no_story" => RegressionReason::NoStory,
328        "no_scenarios" => RegressionReason::NoScenarios {
329            story: get("story")?,
330        },
331        "acceptance_off" => RegressionReason::AcceptanceOff,
332        "author_wrote_none" => RegressionReason::AuthorWroteNone,
333        "author_failed" => RegressionReason::AuthorFailed {
334            detail: get("detail")?,
335        },
336        "already_passing" => RegressionReason::AlreadyPassing,
337        "not_red" => RegressionReason::NotRed {
338            red_class: class_named(&get("red_class")?)?,
339        },
340        "tests_not_collected" => RegressionReason::TestsNotCollected,
341        "planning_work" => RegressionReason::PlanningWork,
342        "story_verified" => RegressionReason::StoryVerified,
343        "in_progress" => RegressionReason::InProgress,
344        "regression_unchecked" => RegressionReason::RegressionUnchecked,
345        _ => return None,
346    })
347}
348
349fn escape(s: &str) -> String {
350    let mut out = String::new();
351    for b in s.bytes() {
352        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
353            out.push(char::from(b));
354        } else {
355            out.push_str(&format!("%{b:02X}"));
356        }
357    }
358    out
359}
360
361fn unescape(s: &str) -> Option<String> {
362    let b = s.as_bytes();
363    let mut out = Vec::with_capacity(b.len());
364    let mut i = 0;
365    while i < b.len() {
366        if b[i] == b'%' {
367            let hex = std::str::from_utf8(b.get(i + 1..i + 3)?).ok()?;
368            out.push(u8::from_str_radix(hex, 16).ok()?);
369            i += 3;
370        } else {
371            out.push(b[i]);
372            i += 1;
373        }
374    }
375    String::from_utf8(out).ok()
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use proptest::prelude::*;
382
383    fn class() -> impl Strategy<Value = RedClass> {
384        prop_oneof![
385            Just(RedClass::Assertion),
386            Just(RedClass::Absent),
387            Just(RedClass::BuildError),
388            Just(RedClass::CollectionError),
389            Just(RedClass::Timeout),
390            Just(RedClass::NoTests),
391            Just(RedClass::Flaky),
392            Just(RedClass::Unclassified),
393            Just(RedClass::Passed),
394        ]
395    }
396
397    fn reason() -> impl Strategy<Value = RegressionReason> {
398        prop_oneof![
399            Just(RegressionReason::NoStory),
400            "[A-Z]{2}[0-9]{1,2}".prop_map(|story| RegressionReason::NoScenarios { story }),
401            Just(RegressionReason::AcceptanceOff),
402            Just(RegressionReason::AuthorWroteNone),
403            ".{0,40}".prop_map(|detail| RegressionReason::AuthorFailed { detail }),
404            Just(RegressionReason::AlreadyPassing),
405            class().prop_map(|red_class| RegressionReason::NotRed { red_class }),
406            Just(RegressionReason::TestsNotCollected),
407            Just(RegressionReason::PlanningWork),
408            Just(RegressionReason::StoryVerified),
409            Just(RegressionReason::InProgress),
410            Just(RegressionReason::RegressionUnchecked),
411        ]
412    }
413
414    proptest! {
415        #[test]
416        fn trailers_round_trip(r in reason()) {
417            prop_assert_eq!(parse_trailer(&trailer(&r)), Some(r));
418        }
419
420        #[test]
421        fn decide_is_total_and_follows_precedence(
422            on in any::<bool>(),
423            story in proptest::option::of("US[0-9]"),
424            scenarios in 0usize..3,
425            skip in proptest::option::of(prop_oneof![
426                Just(SkipCause::NoFiles),
427                Just(SkipCause::NoPlace),
428                Just(SkipCause::WriteFailed { path: "a".to_owned() }),
429            ]),
430            red in proptest::option::of(class()),
431        ) {
432            let f = RegressionFacts { acceptance_on: on, story: story.clone(), scenarios, skip: skip.clone(), red_class: red };
433            let r = decide(&f);
434            let expected = if !on { "acceptance_off" }
435                else if story.is_none() { "no_story" }
436                else if scenarios == 0 { "no_scenarios" }
437                else if matches!(skip, Some(SkipCause::NoPlace | SkipCause::WriteFailed { .. })) { "author_failed" }
438                else if skip.is_some() { "author_wrote_none" }
439                else if red == Some(RedClass::NoTests) { "tests_not_collected" }
440                else if red.is_none() { "author_wrote_none" }
441                else if red == Some(RedClass::Passed) { "already_passing" }
442                else { "not_red" };
443            prop_assert_eq!(r.name(), expected);
444            prop_assert!(!wording(&r).is_empty());
445        }
446    }
447
448    #[test]
449    fn the_wording_is_the_spec_s() {
450        let r = RegressionReason::InProgress;
451        assert_eq!(trailer(&r), "in_progress");
452        assert_eq!(
453            wording(&RegressionReason::AlreadyPassing),
454            "already passing before the attempt"
455        );
456        assert_eq!(
457            wording(&RegressionReason::NotRed {
458                red_class: RedClass::BuildError
459            }),
460            "its acceptance tests did not fail first (build error)"
461        );
462        assert_eq!(
463            trailer(&RegressionReason::NotRed {
464                red_class: RedClass::BuildError
465            }),
466            "not_red red_class=build_error"
467        );
468        let d = detail(&SkipCause::Refused {
469            path: "tests/unit/x.rs".to_owned(),
470            why: "is not where the runner collects tests".to_owned(),
471        });
472        assert_eq!(
473            d,
474            "`tests/unit/x.rs` is not where the runner collects tests"
475        );
476        assert_eq!(
477            serde_json::to_value(&r).unwrap_or_default(),
478            serde_json::json!({ "reason": "in_progress" })
479        );
480        assert!(code_path(&"a".repeat(300)).chars().count() <= 122);
481        assert_eq!(trailer(&RegressionReason::PlanningWork), "planning_work");
482        assert_eq!(
483            parse_trailer("planning_work"),
484            Some(RegressionReason::PlanningWork)
485        );
486        assert_eq!(
487            wording(&RegressionReason::PlanningWork),
488            "planning work — design documents, not code: nothing to test"
489        );
490    }
491}