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