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