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