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