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 StoryVerified,
68 DeferredToCheckpoint {
70 checkpoint: String,
72 },
73 Unrecorded,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
79#[schemars(rename = "RegressionOnly")]
80pub struct Regression {
81 pub reason: RegressionReason,
83 pub text: String,
85}
86
87impl Regression {
88 #[must_use]
90 pub fn of(reason: RegressionReason) -> Self {
91 let text = wording(&reason);
92 Self { reason, text }
93 }
94}
95
96impl RegressionReason {
97 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
120#[serde(tag = "cause", rename_all = "snake_case")]
121pub enum SkipCause {
122 NoFiles,
124 Refused {
126 path: String,
128 why: String,
130 },
131 CommandNotAllowed {
133 argv: String,
135 },
136 WriteFailed {
138 path: String,
140 },
141 NoPlace,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Default)]
147pub struct RegressionFacts {
148 pub acceptance_on: bool,
150 pub story: Option<String>,
152 pub scenarios: usize,
154 pub skip: Option<SkipCause>,
156 pub red_class: Option<RedClass>,
158}
159
160#[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#[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#[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 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#[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
322pub const TRAILER: &str = "Regression-Only";
324
325#[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#[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}