1use serde::{Deserialize, Serialize};
10
11use crate::verify::RedClass;
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
15#[serde(tag = "reason", rename_all = "snake_case")]
16pub enum RegressionReason {
17 NoStory,
19 NoScenarios {
21 story: String,
23 },
24 AcceptanceOff,
26 AuthorWroteNone,
28 AuthorFailed {
30 detail: String,
32 },
33 AlreadyPassing,
35 NotRed {
37 red_class: RedClass,
39 },
40 TestsNotCollected,
42 PlanningWork,
44 StoryVerified,
47 InProgress,
50 RegressionUnchecked,
54 Unrecorded,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
60#[schemars(rename = "RegressionOnly")]
61pub struct Regression {
62 pub reason: RegressionReason,
64 pub text: String,
66}
67
68impl Regression {
69 #[must_use]
71 pub fn of(reason: RegressionReason) -> Self {
72 let text = wording(&reason);
73 Self { reason, text }
74 }
75}
76
77impl RegressionReason {
78 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
102#[serde(tag = "cause", rename_all = "snake_case")]
103pub enum SkipCause {
104 NoFiles,
106 Refused {
108 path: String,
110 why: String,
112 },
113 CommandNotAllowed {
115 argv: String,
117 },
118 WriteFailed {
120 path: String,
122 },
123 NoPlace,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Default)]
129pub struct RegressionFacts {
130 pub acceptance_on: bool,
132 pub story: Option<String>,
134 pub scenarios: usize,
136 pub skip: Option<SkipCause>,
138 pub red_class: Option<RedClass>,
140}
141
142#[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#[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#[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 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#[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
298pub const TRAILER: &str = "Regression-Only";
300
301#[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#[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}