1use serde::{Deserialize, Serialize};
10
11use crate::verify::RedClass;
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
15#[serde(untagged)]
16pub enum ImplementedBy {
17 Task {
19 task: String,
21 commit: String,
23 },
24 BeforeFeature {
26 before_feature: bool,
28 },
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
33#[serde(tag = "reason", rename_all = "snake_case")]
34pub enum RegressionReason {
35 NoStory,
37 NoScenarios {
39 story: String,
41 },
42 AcceptanceOff,
44 AuthorWroteNone,
46 AuthorFailed {
48 detail: String,
50 },
51 AlreadyPassing {
53 implemented_by: Option<ImplementedBy>,
55 },
56 NotRed {
58 red_class: RedClass,
60 },
61 TestsNotCollected,
63 PlanningWork,
65 Unrecorded,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
71#[schemars(rename = "RegressionOnly")]
72pub struct Regression {
73 pub reason: RegressionReason,
75 pub text: String,
77}
78
79impl Regression {
80 #[must_use]
82 pub fn of(reason: RegressionReason) -> Self {
83 let text = wording(&reason);
84 Self { reason, text }
85 }
86}
87
88impl RegressionReason {
89 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
110#[serde(tag = "cause", rename_all = "snake_case")]
111pub enum SkipCause {
112 NoFiles,
114 Refused {
116 path: String,
118 why: String,
120 },
121 CommandNotAllowed {
123 argv: String,
125 },
126 WriteFailed {
128 path: String,
130 },
131 NoPlace,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Default)]
137pub struct RegressionFacts {
138 pub acceptance_on: bool,
140 pub story: Option<String>,
142 pub scenarios: usize,
144 pub skip: Option<SkipCause>,
146 pub red_class: Option<RedClass>,
148}
149
150#[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#[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#[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 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#[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
306pub const TRAILER: &str = "Regression-Only";
308
309#[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#[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}