Skip to main content

speccoder_core/
bug.rs

1//! Bug fixes, the L2 rung: a person's triad — what happens (*Current*),
2//! what should (*Expected*), what must stay as it is (*Unchanged*) — one
3//! reproduction per *Expected* statement that fails on the untouched code
4//! by an assertion, shown to the person once and then frozen, and a fix
5//! judged like any task plus three bug rules: every reproduction goes from
6//! red to green, the pass-to-pass set stays green, and undoing the fix
7//! (the revert) is rejected by at least one reproduction.
8//!
9//! The rules live here, where they are property-tested; running tests and
10//! model calls is the runtime's.
11
12use serde::{Deserialize, Serialize};
13
14use crate::actions::Via;
15use crate::next::NextStep;
16use crate::verify::{HeldOut, RedClass, Verdict};
17
18/// Reproduce tries when `[bug] reproduce_tries` is not set.
19pub const DEFAULT_TRIES: u32 = 3;
20/// The most `[bug] reproduce_tries` allows.
21pub const MAX_TRIES: u32 = 5;
22
23/// One statement of the triad, in the person's words.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
25pub struct Statement {
26    /// `C` (current), `E1`, `E2`, … (expected), `U1`, … (unchanged):
27    /// positional, stable within the requirement.
28    pub id: String,
29    /// The words, whitespace-normalised.
30    pub text: String,
31}
32
33/// The person's three statements about a bug.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
35pub struct Triad {
36    /// What happens now (the symptom).
37    pub current: Statement,
38    /// What should happen instead; at least one.
39    pub expected: Vec<Statement>,
40    /// What must keep working as it does.
41    pub unchanged: Vec<Statement>,
42    /// `sha256:<hex>` over the normalised statements: a different digest
43    /// lapses a confirmation.
44    pub digest: String,
45}
46
47/// Why words are not a triad.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum TriadError {
50    /// The symptom or a statement is empty (`BUG-EMPTY`).
51    Empty,
52    /// No *Expected* statement (`BUG-NO-EXPECTED`).
53    NoExpected,
54}
55
56impl TriadError {
57    /// The catalog code.
58    #[must_use]
59    pub const fn code(self) -> &'static str {
60        match self {
61            Self::Empty => "BUG-EMPTY",
62            Self::NoExpected => "BUG-NO-EXPECTED",
63        }
64    }
65
66    /// What the person is told.
67    #[must_use]
68    pub const fn message(self) -> &'static str {
69        match self {
70            Self::Empty => {
71                "the symptom and every statement need words: `speccoder bug \"<what happens>\" --expected \"<what should>\"`"
72            }
73            Self::NoExpected => {
74                "say what should happen instead: `--expected \"…\"` (one per behaviour)"
75            }
76        }
77    }
78}
79
80/// `text` with every run of whitespace one space, trimmed: how statements
81/// are compared and hashed.
82#[must_use]
83pub fn normalise(text: &str) -> String {
84    text.split_whitespace().collect::<Vec<_>>().join(" ")
85}
86
87impl Triad {
88    /// The triad of the person's words; `hash` gives the hex SHA-256 of
89    /// bytes (the core does no hashing itself).
90    ///
91    /// # Errors
92    ///
93    /// An empty symptom or statement, or no *Expected* statement.
94    pub fn new(
95        current: &str,
96        expected: &[String],
97        unchanged: &[String],
98        hash: impl Fn(&[u8]) -> String,
99    ) -> Result<Self, TriadError> {
100        let current = normalise(current);
101        if current.is_empty() {
102            return Err(TriadError::Empty);
103        }
104        let expected: Vec<String> = expected.iter().map(|e| normalise(e)).collect();
105        let unchanged: Vec<String> = unchanged.iter().map(|u| normalise(u)).collect();
106        if expected.iter().chain(&unchanged).any(String::is_empty) {
107            return Err(TriadError::Empty);
108        }
109        if expected.is_empty() {
110            return Err(TriadError::NoExpected);
111        }
112        let statements = |prefix: char, texts: Vec<String>| -> Vec<Statement> {
113            texts
114                .into_iter()
115                .enumerate()
116                .map(|(i, text)| Statement {
117                    id: format!("{prefix}{}", i + 1),
118                    text,
119                })
120                .collect()
121        };
122        let mut t = Self {
123            current: Statement {
124                id: "C".to_owned(),
125                text: current,
126            },
127            expected: statements('E', expected),
128            unchanged: statements('U', unchanged),
129            digest: String::new(),
130        };
131        t.digest = format!("sha256:{}", hash(t.canonical().as_bytes()));
132        Ok(t)
133    }
134
135    /// The text the digest is over: one `id<TAB>words` line per statement.
136    #[must_use]
137    pub fn canonical(&self) -> String {
138        self.statements()
139            .map(|s| format!("{}\t{}\n", s.id, s.text))
140            .collect()
141    }
142
143    /// Every statement: current, expected, unchanged.
144    pub fn statements(&self) -> impl Iterator<Item = &Statement> {
145        std::iter::once(&self.current)
146            .chain(&self.expected)
147            .chain(&self.unchanged)
148    }
149
150    /// The statement `id`.
151    #[must_use]
152    pub fn statement(&self, id: &str) -> Option<&Statement> {
153        self.statements().find(|s| s.id == id)
154    }
155
156    /// The *Expected* and *Unchanged* ids, the ones tests cite.
157    #[must_use]
158    pub fn cited(&self) -> Vec<String> {
159        self.expected
160            .iter()
161            .chain(&self.unchanged)
162            .map(|s| s.id.clone())
163            .collect()
164    }
165
166    /// The triad as labelled lines, the person's words verbatim: what a
167    /// format's own bug step is given, and what is looked for on read-back.
168    #[must_use]
169    pub fn labelled(&self) -> String {
170        let mut out = format!("Current: {}\n", self.current.text);
171        for e in &self.expected {
172            out.push_str(&format!("Expected: {}\n", e.text));
173        }
174        for u in &self.unchanged {
175            out.push_str(&format!("Unchanged: {}\n", u.text));
176        }
177        out
178    }
179
180    /// A short title: the symptom's first sentence, at most eight words.
181    #[must_use]
182    pub fn title(&self) -> String {
183        let first = self
184            .current
185            .text
186            .split(['.', '!', '?'])
187            .find(|s| !s.trim().is_empty())
188            .unwrap_or(&self.current.text)
189            .trim();
190        let words: Vec<&str> = first.split_whitespace().take(8).collect();
191        let mut t = words.join(" ");
192        if first.split_whitespace().count() > 8 {
193            t.push('…');
194        }
195        t
196    }
197}
198
199/// Where the format keeps the bug.
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
201#[serde(rename_all = "snake_case")]
202pub enum HomeKind {
203    /// Spec Kit's first-party `bug` extension: its own per-bug directory.
204    NativeSpeckitBug,
205    /// Kiro's `bugfix.md` spec.
206    NativeKiroBugfix,
207    /// Spec Kit without the extension: the standing bug-fix unit.
208    StandingSpeckit,
209    /// OpenSpec: the standing bug-fix change.
210    StandingOpenspec,
211}
212
213impl HomeKind {
214    /// The format's own bug structure (no task list: the task is
215    /// `bug:<slug>`, ticked nowhere).
216    #[must_use]
217    pub const fn native(self) -> bool {
218        matches!(self, Self::NativeSpeckitBug | Self::NativeKiroBugfix)
219    }
220
221    /// Its recorded name.
222    #[must_use]
223    pub const fn as_str(self) -> &'static str {
224        match self {
225            Self::NativeSpeckitBug => "native_speckit_bug",
226            Self::NativeKiroBugfix => "native_kiro_bugfix",
227            Self::StandingSpeckit => "standing_speckit",
228            Self::StandingOpenspec => "standing_openspec",
229        }
230    }
231}
232
233/// The bug's home, as the format adapter chose it.
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
235pub struct BugHome {
236    /// Which structure.
237    pub kind: HomeKind,
238    /// Project-relative directory (or file).
239    pub location: String,
240    /// Why this one, as shown and recorded.
241    pub reason: String,
242}
243
244/// Where the bug is.
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
246#[serde(rename_all = "snake_case")]
247#[schemars(rename = "BugStage")]
248pub enum Stage {
249    /// The triad is being recorded.
250    Record,
251    /// Reproductions are being written and shown to fail.
252    Reproduce,
253    /// The reproductions wait for the person's confirmation.
254    Confirm,
255    /// The pass-to-pass set is chosen and run.
256    Unchanged,
257    /// The fix is attempted and judged.
258    Fix,
259    /// The verdict commit.
260    Ship,
261    /// Nothing more happens without the person.
262    Done,
263}
264
265/// The bug's outcome; distinct from a task's verdict.
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
267#[serde(rename_all = "snake_case")]
268#[schemars(rename = "BugOutcome")]
269pub enum Outcome {
270    /// The triad is recorded; nothing reproduced yet.
271    Recorded,
272    /// Every statement is reproduced; the person confirms.
273    AwaitingConfirmation,
274    /// The tries ran out without a reproduction of every statement.
275    CannotReproduce,
276    /// Every reproduction passed on the untouched code, on every try.
277    AlreadySatisfied,
278    /// An *Unchanged* statement does not hold today.
279    UnchangedNotHolding,
280    /// The fix was judged; see the verdict.
281    Fixed,
282    /// The fix did not pass.
283    Failed,
284}
285
286impl Outcome {
287    /// Its recorded name.
288    #[must_use]
289    pub const fn as_str(self) -> &'static str {
290        match self {
291            Self::Recorded => "recorded",
292            Self::AwaitingConfirmation => "awaiting_confirmation",
293            Self::CannotReproduce => "cannot_reproduce",
294            Self::AlreadySatisfied => "already_satisfied",
295            Self::UnchangedNotHolding => "unchanged_not_holding",
296            Self::Fixed => "fixed",
297            Self::Failed => "failed",
298        }
299    }
300}
301
302/// Who wrote a reproduction.
303#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
304#[serde(rename_all = "snake_case")]
305#[schemars(rename = "ReproductionSource")]
306pub enum Source {
307    /// The test-blind reproduction author.
308    Author,
309    /// The person (`--test`).
310    Person,
311}
312
313/// A reproduction on the final tree.
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
315#[serde(rename_all = "snake_case")]
316#[schemars(rename = "FinalState")]
317pub enum Final {
318    /// It passed.
319    Passed,
320    /// It failed.
321    Failed,
322    /// Not run (no attempt yet).
323    NotRun,
324}
325
326/// A reproduction against the revert candidate (the fix undone).
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
328#[serde(rename_all = "snake_case")]
329#[schemars(rename = "RevertOutcome")]
330pub enum Revert {
331    /// It failed on an assertion: it tells the fix from no fix.
332    Rejected,
333    /// It passed without the fix.
334    Survived,
335    /// The revert did not build: no evidence either way.
336    Unviable,
337    /// Not run.
338    NotRun,
339}
340
341/// One reproduction test.
342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
343pub struct Reproduction {
344    /// The statement it reproduces (`E1`) — or, for a test of an
345    /// *Unchanged* statement, that one (`U1`).
346    pub statement: String,
347    /// Project-relative test file.
348    pub file: String,
349    /// The test's name.
350    pub test: String,
351    /// Who wrote it.
352    pub source: Source,
353    /// Its class on the untouched code, run twice.
354    pub baseline: Vec<RedClass>,
355    /// `sha256:<hex>` of its content.
356    pub digest: String,
357    /// The command that runs only it.
358    pub argv: Vec<String>,
359    /// On the final tree.
360    #[serde(rename = "final")]
361    pub final_: Final,
362    /// Against the revert candidate.
363    pub revert: Revert,
364}
365
366impl Reproduction {
367    /// A reproduction of an *Expected* statement (not an *Unchanged* test).
368    #[must_use]
369    pub fn reproduces(&self) -> bool {
370        self.statement.starts_with('E')
371    }
372
373    /// Red by assertion on both baseline runs: the bug red rule (an absent
374    /// symbol is not a bug's red).
375    #[must_use]
376    pub fn red(&self) -> bool {
377        self.baseline.len() >= 2 && self.baseline.iter().all(|c| *c == RedClass::Assertion)
378    }
379}
380
381/// A held-out variant, as the payload shows it: never its content or path.
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
383pub struct HeldOutVariant {
384    /// The statement it covers.
385    pub statement: String,
386    /// On the untouched code.
387    pub baseline: RedClass,
388    /// On the final tree.
389    #[serde(rename = "final")]
390    pub final_: Final,
391}
392
393/// One statement's class in one try.
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
395pub struct TryStatement {
396    /// `E1`, `U1`.
397    pub statement: String,
398    /// What its test did on the untouched code (`assertion` only when both
399    /// runs failed on one).
400    pub class: RedClass,
401    /// Up to 20 lines of the run, when it was not red.
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    pub excerpt: Option<String>,
404}
405
406/// One try of the reproduction author.
407#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
408pub struct ReproduceTry {
409    /// 1, 2, …
410    pub n: u32,
411    /// Per statement.
412    pub per_statement: Vec<TryStatement>,
413    /// Why the whole try was discarded (`wrote outside tests`, …).
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub discarded: Option<String>,
416    /// The author's run.
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub run_id: Option<String>,
419    /// Its tokens.
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub usage: Option<serde_json::Value>,
422    /// The person's reason for rejecting the reproductions before it.
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub rejection: Option<String>,
425}
426
427/// What reproduce decided.
428#[derive(Debug, Clone, PartialEq, Eq)]
429pub enum Reproduce {
430    /// Every *Expected* statement is red by assertion in one try.
431    Reproduced {
432        /// The try (1-based).
433        n: u32,
434    },
435    /// Every statement passed on every try.
436    AlreadySatisfied,
437    /// Another try is allowed.
438    Retry,
439    /// The tries ran out.
440    CannotReproduce {
441        /// Red by assertion in some try.
442        reproduced: Vec<String>,
443        /// Passed in every try that ran it.
444        satisfied: Vec<String>,
445        /// Neither.
446        not_demonstrated: Vec<String>,
447    },
448}
449
450/// The class of `statement` in `t`, if it ran there.
451fn class_in<'t>(t: &'t ReproduceTry, statement: &str) -> Option<&'t RedClass> {
452    t.per_statement
453        .iter()
454        .find(|s| s.statement == statement)
455        .map(|s| &s.class)
456}
457
458/// What the tries so far decide, for the *Expected* statements `expected`
459/// and at most `max` tries. Pure; never reads the author's words.
460///
461/// Only the latest try can reproduce: an earlier one either was shown and
462/// rejected (then it is discarded) or was superseded by a later try. Every
463/// try counts against `max`, a discarded or rejected one too, so the author
464/// is called at most `max` times; a discarded try is never evidence of a
465/// statement either way.
466#[must_use]
467pub fn decide_reproduce(tries: &[ReproduceTry], expected: &[String], max: u32) -> Reproduce {
468    if let Some(t) = tries.last().filter(|t| {
469        t.discarded.is_none()
470            && !expected.is_empty()
471            && expected
472                .iter()
473                .all(|e| class_in(t, e) == Some(&RedClass::Assertion))
474    }) {
475        return Reproduce::Reproduced { n: t.n };
476    }
477    let made = u32::try_from(tries.len()).unwrap_or(u32::MAX);
478    if made < max.max(1) {
479        return Reproduce::Retry;
480    }
481    let all_passed = !tries.is_empty()
482        && tries.iter().all(|t| {
483            t.discarded.is_none()
484                && expected
485                    .iter()
486                    .all(|e| class_in(t, e) == Some(&RedClass::Passed))
487        });
488    if all_passed {
489        return Reproduce::AlreadySatisfied;
490    }
491    let mut reproduced = Vec::new();
492    let mut satisfied = Vec::new();
493    let mut not_demonstrated = Vec::new();
494    for e in expected {
495        let classes: Vec<&RedClass> = tries
496            .iter()
497            .filter(|t| t.discarded.is_none())
498            .filter_map(|t| class_in(t, e))
499            .collect();
500        if classes.contains(&&RedClass::Assertion) {
501            reproduced.push(e.clone());
502        } else if !classes.is_empty() && classes.iter().all(|c| **c == RedClass::Passed) {
503            satisfied.push(e.clone());
504        } else {
505            not_demonstrated.push(e.clone());
506        }
507    }
508    Reproduce::CannotReproduce {
509        reproduced,
510        satisfied,
511        not_demonstrated,
512    }
513}
514
515/// Why a try was discarded when the person rejected what it reproduced.
516#[must_use]
517pub fn rejected_try(reason: &str) -> String {
518    format!("rejected by the person: {reason}")
519}
520
521/// The most a bug slug may be long.
522pub const MAX_SLUG: usize = 64;
523
524/// Whether `slug` names a bug directory: lowercase ASCII letters, digits
525/// and `-`, starting with a letter or digit, at most [`MAX_SLUG`] long —
526/// never a path (`..`, a separator) whatever its source (a flag, an
527/// interface, a commit trailer of an untrusted pull request).
528#[must_use]
529pub fn valid_slug(slug: &str) -> bool {
530    !slug.is_empty()
531        && slug.len() <= MAX_SLUG
532        && slug
533            .bytes()
534            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
535        && slug
536            .bytes()
537            .next()
538            .is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
539}
540
541/// The class a reproduction counts as from its two baseline runs: red only
542/// by an assertion in both; red once, then not, is flaky; an absent symbol
543/// is not a bug's red.
544#[must_use]
545pub fn bug_class(first: RedClass, second: Option<RedClass>) -> RedClass {
546    match (first, second) {
547        (RedClass::Assertion, Some(RedClass::Assertion)) => RedClass::Assertion,
548        (RedClass::Assertion, _) => RedClass::Flaky,
549        (c, _) => c,
550    }
551}
552
553/// How the person decided the reproductions.
554#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
555#[serde(rename_all = "snake_case")]
556pub enum Basis {
557    /// They saw the reproductions and confirmed them.
558    Shown,
559    /// They named the tests themselves (`--test`).
560    NamedTest,
561}
562
563/// The person's one confirmation, bound to what they saw.
564#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
565pub struct Confirmation {
566    /// `sha256:<hex>` over the reproductions shown (their files and
567    /// statement ids).
568    pub digest: String,
569    /// The triad's digest when confirmed: a different one lapses this.
570    pub triad_digest: String,
571    /// Who.
572    pub by: String,
573    /// Through what.
574    pub via: Via,
575    /// Seen or named.
576    pub basis: Basis,
577    /// Unix seconds.
578    pub at: u64,
579}
580
581impl Confirmation {
582    /// Still valid for the triad as it reads now.
583    #[must_use]
584    pub fn current(&self, triad_digest: &str) -> bool {
585        self.triad_digest == triad_digest
586    }
587}
588
589/// The person rejected the reproductions shown; the reason goes to the
590/// author's next try.
591#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
592pub struct Rejection {
593    /// The digest rejected.
594    pub digest: String,
595    /// Why.
596    pub reason: String,
597    /// Who.
598    pub by: String,
599    /// Through what.
600    pub via: Via,
601    /// Unix seconds.
602    pub at: u64,
603}
604
605/// Whether anything was selected for pass-to-pass.
606#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
607#[serde(rename_all = "snake_case")]
608pub enum PassToPassState {
609    /// Members were selected.
610    Selected,
611    /// Nothing was: never counted as passed.
612    NoneSelected,
613}
614
615/// A pass-to-pass member on the final tree.
616#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
617#[serde(rename_all = "snake_case")]
618pub enum MemberFinal {
619    /// Passed, by name.
620    Passed,
621    /// Failed.
622    Failed,
623    /// Not found in the final run: a failure.
624    Missing,
625    /// Not run yet.
626    NotRun,
627}
628
629/// One test that must stay green.
630#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
631#[schemars(rename = "PassToPassMember")]
632pub struct Member {
633    /// The test id, as the runner reports it.
634    pub test: String,
635    /// Its file.
636    pub file: String,
637    /// How it was selected: `same_file`, `references:<module>`,
638    /// `imports:<path>`, `package:<pkg>`, `unchanged:U<n>`.
639    pub rule: String,
640    /// On the final tree.
641    #[serde(rename = "final")]
642    pub final_: MemberFinal,
643}
644
645/// The tests that passed before the fix and must pass after it.
646#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
647pub struct PassToPass {
648    /// Selected, or nothing was.
649    pub state: PassToPassState,
650    /// The members.
651    pub members: Vec<Member>,
652}
653
654impl PassToPass {
655    /// Every member passed by name (vacuously true when none was selected,
656    /// which is recorded as such, never as passed).
657    #[must_use]
658    pub fn held(&self) -> bool {
659        self.members.iter().all(|m| m.final_ == MemberFinal::Passed)
660    }
661}
662
663/// The revert candidate's result.
664#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
665pub struct RevertResult {
666    /// The gravest outcome over the reproductions.
667    pub outcome: Revert,
668    /// The statements whose reproduction failed on an assertion.
669    pub rejected_by: Vec<String>,
670}
671
672/// One special-case candidate: a fix that recognises a reproduction's
673/// literal inputs.
674#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
675pub struct SpecialCase {
676    /// The statement whose reproduction it targets.
677    pub statement: String,
678    /// `sha256:<hex>` of its files: what a ruling is bound to.
679    pub digest: String,
680    /// The combined set (reproductions and held-out variants) rejected it.
681    pub rejected: bool,
682    /// Its statement's held-out variant was written again once, told it.
683    pub regenerated: bool,
684    /// A person ruled the tests right about it.
685    #[serde(default)]
686    pub ruled: bool,
687}
688
689/// Everything that shows the reproductions tell a fixed tree from a
690/// buggy one.
691#[derive(Debug, Clone, Copy)]
692pub struct Discrimination<'a> {
693    /// The reproductions (with `final` and `revert`).
694    pub reproductions: &'a [Reproduction],
695    /// The revert candidate.
696    pub revert: &'a RevertResult,
697    /// The special-case candidates.
698    pub special_case: &'a [SpecialCase],
699    /// Why the special-case author gave none (its call failed, it did
700    /// not answer, its candidates were not real changes).
701    pub special_case_error: Option<&'a str>,
702}
703
704/// The verdict on a bug task: `base` (the ordinary rules, held-out
705/// included) stands only if every reproduction passed on the final tree,
706/// the revert was rejected by an assertion, every reproducing statement has
707/// a special case and each was rejected (or ruled), the pass-to-pass set
708/// passed by name and the held-out variants passed. Reasons name
709/// statements, never a held-out test.
710#[must_use]
711pub fn bug_verdict(
712    base: Verdict,
713    d: &Discrimination<'_>,
714    p2p: &PassToPass,
715    held_out: &HeldOut,
716) -> Verdict {
717    let mut reasons = Vec::new();
718    for r in d.reproductions {
719        if r.final_ != Final::Passed {
720            reasons.push(format!(
721                "the reproduction of {} does not pass on the fixed tree",
722                r.statement
723            ));
724        }
725    }
726    if d.revert.rejected_by.is_empty() {
727        reasons.push(match d.revert.outcome {
728            Revert::Unviable => "the reproduction does not discriminate the fix: the revert does not build, and no reproduction rejects it by an assertion".to_owned(),
729            _ => "the reproduction does not discriminate the fix: with the change undone the reproductions still pass".to_owned(),
730        });
731    }
732    // At least one special case per reproducing statement: a
733    // statement without one has no evidence the reproduction is strong.
734    let mut reproducing: Vec<&str> = d
735        .reproductions
736        .iter()
737        .filter(|r| r.reproduces())
738        .map(|r| r.statement.as_str())
739        .collect();
740    reproducing.dedup();
741    for st in reproducing {
742        if !d.special_case.iter().any(|s| s.statement == st) {
743            reasons.push(format!(
744                "no special-case candidate for {st} ({}): the reproduction's strength is not shown",
745                d.special_case_error
746                    .unwrap_or("the special-case author wrote none")
747            ));
748        }
749    }
750    for s in d.special_case {
751        if !s.rejected && !s.ruled {
752            reasons.push(format!(
753                "reproduction too weak: a fix that special-cases {}'s input passes the reproductions and the held-out variants",
754                s.statement
755            ));
756        }
757    }
758    for m in &p2p.members {
759        match m.final_ {
760            MemberFinal::Passed => {}
761            MemberFinal::Missing => {
762                reasons.push(format!("pass-to-pass: {} is missing", m.test));
763            }
764            MemberFinal::Failed | MemberFinal::NotRun => {
765                reasons.push(format!("pass-to-pass: {} does not pass", m.test));
766            }
767        }
768    }
769    match held_out {
770        HeldOut::Passed { .. } | HeldOut::Off => {}
771        HeldOut::Absent { reason } => reasons.push(format!(
772            "no held-out variant was kept ({reason}): the fix cannot be checked on other inputs"
773        )),
774        HeldOut::Failed { scenarios } => reasons.push(format!(
775            "held-out variant failed for {}",
776            scenarios.join(", ")
777        )),
778    }
779    match base {
780        Verdict::Failed {
781            reasons: mut before,
782        } => {
783            for r in reasons {
784                if !before.contains(&r) {
785                    before.push(r);
786                }
787            }
788            Verdict::Failed { reasons: before }
789        }
790        v if reasons.is_empty() => v,
791        _ => Verdict::Failed { reasons },
792    }
793}
794
795/// The author's own words, kept only as a labelled claim.
796#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
797#[schemars(rename = "AuthorClaim")]
798pub struct Claim {
799    /// The words.
800    pub text: String,
801    /// Always `author`.
802    pub by: String,
803}
804
805/// Statements, by what the tries showed.
806#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
807#[schemars(rename = "StatementsShown")]
808pub struct Statements {
809    /// Red by assertion in some try.
810    pub reproduced: Vec<String>,
811    /// Neither red nor passing.
812    pub not_reproduced: Vec<String>,
813    /// Passed on every try.
814    pub satisfied: Vec<String>,
815}
816
817/// The hand-off of a stopped bug, composed by the engine from the journal
818/// (never written by a model).
819#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
820pub struct Handoff {
821    /// The outcome it hands off.
822    pub outcome: Outcome,
823    /// The tries made.
824    pub tries: Vec<ReproduceTry>,
825    /// Per statement.
826    pub statements: Statements,
827    /// Tokens the tries spent.
828    #[serde(default, skip_serializing_if = "Option::is_none")]
829    pub cost: Option<serde_json::Value>,
830    /// How long reproduce took.
831    pub elapsed_ms: u64,
832    /// What the person can do.
833    pub next: Vec<NextStep>,
834    /// The author's last words, labelled as its claim.
835    #[serde(default, skip_serializing_if = "Option::is_none")]
836    pub claim: Option<Claim>,
837}
838
839/// Where a foreign fix came from.
840#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
841#[serde(rename_all = "snake_case")]
842pub enum CandidateOrigin {
843    /// A patch file.
844    Patch,
845    /// A GitHub pull request.
846    Pr,
847}
848
849/// Why a candidate was refused before it was applied.
850#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
851pub struct CandidateRefusal {
852    /// `BUG-CANDIDATE-JUDGE` or `BUG-CANDIDATE-APPLY`.
853    pub code: String,
854    /// The judge paths it touches, or the hunks that did not apply.
855    pub paths: Vec<String>,
856}
857
858/// Someone else's fix, judged as the attempt.
859#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
860pub struct Candidate {
861    /// A patch or a pull request.
862    pub origin: CandidateOrigin,
863    /// The patch file, as given.
864    #[serde(default, skip_serializing_if = "Option::is_none")]
865    pub path: Option<String>,
866    /// The pull request's number.
867    #[serde(default, skip_serializing_if = "Option::is_none")]
868    pub number: Option<u64>,
869    /// `sha256:<hex>` of the diff.
870    pub digest: String,
871    /// The pull request's head commit.
872    #[serde(default, skip_serializing_if = "Option::is_none")]
873    pub head_sha: Option<String>,
874    /// It was applied as the attempt.
875    pub applied: bool,
876    /// Why it was not.
877    #[serde(default, skip_serializing_if = "Option::is_none")]
878    pub refused: Option<CandidateRefusal>,
879}
880
881/// The issue a bug was recorded from.
882#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
883pub struct IssueRef {
884    /// Its number.
885    pub number: u64,
886    /// `owner/repo`, when known.
887    #[serde(default, skip_serializing_if = "Option::is_none")]
888    pub repo: Option<String>,
889    /// The digest of its context-manifest entry (title and body as given
890    /// to the reproduction author).
891    #[serde(default, skip_serializing_if = "Option::is_none")]
892    pub context_digest: Option<String>,
893}
894
895/// Where the confirmed reproductions are kept, as recorded with the fix.
896#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
897pub struct PreFrozenRef {
898    /// `sha256:<hex>` over the visible set.
899    pub manifest: String,
900    /// The confirmation's digest.
901    pub approval: String,
902    /// The triad's digest.
903    pub wording: String,
904}
905
906/// What the fix's visible acceptance set came from.
907#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
908pub struct AcceptanceRef {
909    /// `bug_confirmation` or `person_test`.
910    pub source: crate::acceptance::AcceptanceSource,
911    /// The record it reused.
912    pub pre_frozen: PreFrozenRef,
913}
914
915/// A bug: where it lives, the person's triad, the reproductions and how
916/// far it got. The `bug` payload, and what speccoder keeps of it.
917#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
918pub struct BugReport {
919    /// Where the format keeps it.
920    pub home: BugHome,
921    /// The unit, as the adapter names it (`007-bugfixes`, `bugs/<slug>`).
922    pub unit: String,
923    /// The requirement (`FR-003`, or `bug:<slug>`).
924    pub requirement: String,
925    /// The task (`T003`, or `bug:<slug>`).
926    pub task: String,
927    /// A short title.
928    pub title: String,
929    /// The person's words.
930    pub triad: Triad,
931    /// Where it is.
932    pub stage: Stage,
933    /// How it stands.
934    pub outcome: Outcome,
935    /// The fix's verdict, once judged.
936    #[serde(default)]
937    pub verdict: Option<Verdict>,
938    /// The code of the last stop or refusal.
939    #[serde(default)]
940    pub code: Option<String>,
941    /// The reproductions (and *Unchanged* tests).
942    #[serde(default)]
943    pub reproductions: Vec<Reproduction>,
944    /// The held-out variants (never their content or path).
945    #[serde(default)]
946    pub held_out: Vec<HeldOutVariant>,
947    /// The sealed set holding them.
948    #[serde(default)]
949    pub held_out_sealed: Option<crate::acceptance::SealedRef>,
950    /// Why no held-out variant is sealed (the author's call failed, none
951    /// was red): the verdict names it, and the confirmation says it first.
952    #[serde(default, skip_serializing_if = "Option::is_none")]
953    pub held_out_reason: Option<String>,
954    /// The digest waiting for the person's confirmation.
955    #[serde(default)]
956    pub confirm_digest: Option<String>,
957    /// The confirmation.
958    #[serde(default)]
959    pub confirmation: Option<Confirmation>,
960    /// Rejections of earlier reproductions.
961    #[serde(default)]
962    pub rejections: Vec<Rejection>,
963    /// What the fix's acceptance set came from.
964    #[serde(default)]
965    pub acceptance: Option<AcceptanceRef>,
966    /// The tests selected to stay green.
967    #[serde(default)]
968    pub pass_to_pass: Option<PassToPass>,
969    /// The revert candidate.
970    #[serde(default)]
971    pub revert: Option<RevertResult>,
972    /// The special-case candidates.
973    #[serde(default)]
974    pub special_case: Vec<SpecialCase>,
975    /// The reproduce tries.
976    #[serde(default)]
977    pub tries: Vec<ReproduceTry>,
978    /// At most this many tries.
979    pub max_tries: u32,
980    /// The engine's hand-off, when it stopped.
981    #[serde(default)]
982    pub handoff: Option<Handoff>,
983    /// The issue it came from.
984    #[serde(default)]
985    pub issue: Option<IssueRef>,
986    /// Someone else's fix, judged.
987    #[serde(default)]
988    pub candidate: Option<Candidate>,
989    /// The planning commit that recorded it.
990    #[serde(default)]
991    pub recorded_commit: Option<String>,
992    /// The verdict commit.
993    #[serde(default)]
994    pub commit: Option<String>,
995    /// The last run.
996    #[serde(default)]
997    pub run_id: Option<String>,
998}
999
1000impl BugReport {
1001    /// The *Expected* ids.
1002    #[must_use]
1003    pub fn expected_ids(&self) -> Vec<String> {
1004        self.triad.expected.iter().map(|s| s.id.clone()).collect()
1005    }
1006
1007    /// `<unit>/<task>`: how commands name it.
1008    #[must_use]
1009    pub fn key(&self) -> String {
1010        format!("{}/{}", self.unit, self.task)
1011    }
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016    use proptest::prelude::*;
1017
1018    use super::*;
1019
1020    fn hash(b: &[u8]) -> String {
1021        // Not SHA-256: a stand-in; the runtime passes the real one.
1022        format!(
1023            "{:016x}",
1024            b.iter().fold(0xcbf2_9ce4_8422_2325_u64, |h, x| {
1025                (h ^ u64::from(*x)).wrapping_mul(0x0100_0000_01b3)
1026            })
1027        )
1028    }
1029
1030    fn strings(v: &[&str]) -> Vec<String> {
1031        v.iter().map(|s| (*s).to_owned()).collect()
1032    }
1033
1034    #[test]
1035    fn a_triad_numbers_its_statements_and_refuses_empty_words() {
1036        let t = Triad::new(
1037            "  saving an empty   title crashes ",
1038            &strings(&["an empty title is refused", "the form keeps its text"]),
1039            &strings(&["a title is saved"]),
1040            hash,
1041        )
1042        .unwrap_or_else(|e| panic!("{e:?}"));
1043        assert_eq!(t.current.text, "saving an empty title crashes");
1044        assert_eq!(t.cited(), ["E1", "E2", "U1"]);
1045        assert!(t.digest.starts_with("sha256:"));
1046        assert_eq!(
1047            t.labelled(),
1048            "Current: saving an empty title crashes\nExpected: an empty title is refused\nExpected: the form keeps its text\nUnchanged: a title is saved\n"
1049        );
1050        assert_eq!(t.title(), "saving an empty title crashes");
1051        assert_eq!(
1052            Triad::new(" ", &strings(&["x"]), &[], hash),
1053            Err(TriadError::Empty)
1054        );
1055        assert_eq!(Triad::new("x", &[], &[], hash), Err(TriadError::NoExpected));
1056        assert_eq!(
1057            Triad::new("x", &strings(&["  "]), &[], hash),
1058            Err(TriadError::Empty)
1059        );
1060        assert_eq!(TriadError::NoExpected.code(), "BUG-NO-EXPECTED");
1061    }
1062
1063    fn try_of(n: u32, classes: &[(&str, RedClass)]) -> ReproduceTry {
1064        ReproduceTry {
1065            n,
1066            per_statement: classes
1067                .iter()
1068                .map(|(s, c)| TryStatement {
1069                    statement: (*s).to_owned(),
1070                    class: *c,
1071                    excerpt: None,
1072                })
1073                .collect(),
1074            discarded: None,
1075            run_id: None,
1076            usage: None,
1077            rejection: None,
1078        }
1079    }
1080
1081    fn red_class() -> impl Strategy<Value = RedClass> {
1082        prop_oneof![
1083            Just(RedClass::Assertion),
1084            Just(RedClass::Absent),
1085            Just(RedClass::BuildError),
1086            Just(RedClass::CollectionError),
1087            Just(RedClass::Timeout),
1088            Just(RedClass::NoTests),
1089            Just(RedClass::Flaky),
1090            Just(RedClass::Unclassified),
1091            Just(RedClass::Passed),
1092        ]
1093    }
1094
1095    proptest! {
1096        #[test]
1097        fn reproduce_is_decided_by_classes_alone(
1098            classes in proptest::collection::vec(proptest::collection::vec(red_class(), 2), 1..6),
1099            max in 1u32..6,
1100        ) {
1101            let expected = strings(&["E1", "E2"]);
1102            let tries: Vec<ReproduceTry> = classes
1103                .iter()
1104                .enumerate()
1105                .map(|(i, c)| try_of(u32::try_from(i).unwrap_or(0) + 1, &[("E1", c[0]), ("E2", c[1])]))
1106                .collect();
1107            // Only the latest try reproduces: an earlier one was superseded.
1108            let all_red = tries.last().is_some_and(|t| t.per_statement.iter().all(|s| s.class == RedClass::Assertion));
1109            match decide_reproduce(&tries, &expected, max) {
1110                Reproduce::Reproduced { n } => {
1111                    prop_assert!(all_red);
1112                    prop_assert_eq!(n as usize, tries.len());
1113                    let t = &tries[(n - 1) as usize];
1114                    // Absent or flaky is never a bug's red.
1115                    prop_assert!(t.per_statement.iter().all(|s| s.class == RedClass::Assertion));
1116                }
1117                Reproduce::AlreadySatisfied => {
1118                    prop_assert!(!all_red);
1119                    prop_assert!(tries.len() >= max as usize);
1120                    prop_assert!(tries.iter().all(|t| t.per_statement.iter().all(|s| s.class == RedClass::Passed)));
1121                }
1122                Reproduce::Retry => {
1123                    prop_assert!(!all_red);
1124                    prop_assert!(tries.len() < max as usize);
1125                }
1126                Reproduce::CannotReproduce { reproduced, satisfied, not_demonstrated } => {
1127                    prop_assert!(!all_red);
1128                    prop_assert!(tries.len() >= max as usize);
1129                    let mut named: Vec<String> = reproduced.iter().chain(&satisfied).chain(&not_demonstrated).cloned().collect();
1130                    named.sort();
1131                    prop_assert_eq!(named, expected.clone());
1132                }
1133            }
1134        }
1135
1136        #[test]
1137        fn a_bug_is_red_only_by_two_assertions(a in red_class(), b in red_class()) {
1138            let c = bug_class(a, Some(b));
1139            prop_assert_eq!(c == RedClass::Assertion, a == RedClass::Assertion && b == RedClass::Assertion);
1140        }
1141
1142        #[test]
1143        fn verified_needs_every_bug_rule(
1144            finals in proptest::collection::vec(prop_oneof![Just(Final::Passed), Just(Final::Failed), Just(Final::NotRun)], 1..4),
1145            rejected_by in proptest::collection::vec("E[1-3]", 0..3),
1146            special in proptest::collection::vec((any::<bool>(), any::<bool>()), 0..3),
1147            members in proptest::collection::vec(prop_oneof![Just(MemberFinal::Passed), Just(MemberFinal::Failed), Just(MemberFinal::Missing)], 0..4),
1148            held in prop_oneof![
1149                Just(HeldOut::Passed { files: 1 }),
1150                Just(HeldOut::Failed { scenarios: vec!["E1".to_owned()] }),
1151                Just(HeldOut::Absent { reason: "all_discarded".to_owned() }),
1152                Just(HeldOut::Off),
1153            ],
1154        ) {
1155            let reproductions: Vec<Reproduction> = finals.iter().enumerate().map(|(i, f)| Reproduction {
1156                statement: format!("E{}", i + 1),
1157                file: format!("tests/repro_{i}.rs"),
1158                test: "t".to_owned(),
1159                source: Source::Author,
1160                baseline: vec![RedClass::Assertion, RedClass::Assertion],
1161                digest: "sha256:x".to_owned(),
1162                argv: Vec::new(),
1163                final_: *f,
1164                revert: Revert::NotRun,
1165            }).collect();
1166            let revert = RevertResult { outcome: if rejected_by.is_empty() { Revert::Survived } else { Revert::Rejected }, rejected_by: rejected_by.clone() };
1167            let special_case: Vec<SpecialCase> = special.iter().enumerate().map(|(i, (rejected, ruled))| SpecialCase {
1168                statement: format!("E{}", i + 1), digest: "sha256:s".to_owned(), rejected: *rejected, regenerated: false, ruled: *ruled,
1169            }).collect();
1170            let p2p = PassToPass {
1171                state: if members.is_empty() { PassToPassState::NoneSelected } else { PassToPassState::Selected },
1172                members: members.iter().map(|m| Member { test: "held_out_secret_name".to_owned(), file: "tests/a.rs".to_owned(), rule: "same_file".to_owned(), final_: *m }).collect(),
1173            };
1174            let d = Discrimination { reproductions: &reproductions, revert: &revert, special_case: &special_case, special_case_error: None };
1175            let v = bug_verdict(Verdict::Verified, &d, &p2p, &held);
1176            let ok = finals.iter().all(|f| *f == Final::Passed)
1177                && !rejected_by.is_empty()
1178                && (1..=finals.len()).all(|i| special_case.iter().any(|s| s.statement == format!("E{i}")))
1179                && special.iter().all(|(r, ruled)| *r || *ruled)
1180                && members.iter().all(|m| *m == MemberFinal::Passed)
1181                && matches!(held, HeldOut::Passed { .. } | HeldOut::Off);
1182            prop_assert_eq!(v == Verdict::Verified, ok);
1183            if let Verdict::Failed { reasons } = &v {
1184                // A held-out failure names statements only.
1185                prop_assert!(reasons.iter().all(|r| !r.contains("held_out_") || r.starts_with("pass-to-pass")));
1186            }
1187            // A failed base stays failed.
1188            let failed = bug_verdict(Verdict::Failed { reasons: vec!["check `x` failed".to_owned()] }, &d, &p2p, &held);
1189            prop_assert!(matches!(failed, Verdict::Failed { .. }), "a failed verdict stays failed");
1190        }
1191    }
1192
1193    #[test]
1194    fn absent_and_flaky_are_not_a_bugs_red() {
1195        assert_eq!(
1196            bug_class(RedClass::Absent, Some(RedClass::Absent)),
1197            RedClass::Absent
1198        );
1199        assert_eq!(
1200            bug_class(RedClass::Assertion, Some(RedClass::Passed)),
1201            RedClass::Flaky
1202        );
1203        assert_eq!(bug_class(RedClass::Assertion, None), RedClass::Flaky);
1204        let tries = [try_of(1, &[("E1", RedClass::Absent)])];
1205        assert!(matches!(
1206            decide_reproduce(&tries, &strings(&["E1"]), 1),
1207            Reproduce::CannotReproduce { .. }
1208        ));
1209    }
1210
1211    #[test]
1212    fn a_rejected_try_is_discarded_and_the_tries_stay_bounded() {
1213        let e1 = strings(&["E1"]);
1214        let mut rejected = try_of(1, &[("E1", RedClass::Assertion)]);
1215        rejected.discarded = Some(rejected_try("it tests the wrong field"));
1216        // The rejected try never reproduces again: the author tries anew.
1217        assert_eq!(
1218            decide_reproduce(std::slice::from_ref(&rejected), &e1, 3),
1219            Reproduce::Retry
1220        );
1221        let again = try_of(2, &[("E1", RedClass::Assertion)]);
1222        assert_eq!(
1223            decide_reproduce(&[rejected.clone(), again], &e1, 3),
1224            Reproduce::Reproduced { n: 2 }
1225        );
1226        // Every try rejected: the tries run out, nothing counts as reproduced.
1227        let all: Vec<ReproduceTry> = (1..=3)
1228            .map(|n| ReproduceTry {
1229                n,
1230                ..rejected.clone()
1231            })
1232            .collect();
1233        assert_eq!(
1234            decide_reproduce(&all, &e1, 3),
1235            Reproduce::CannotReproduce {
1236                reproduced: Vec::new(),
1237                satisfied: Vec::new(),
1238                not_demonstrated: e1.clone(),
1239            }
1240        );
1241        // An earlier reproducing try is superseded by a later one that is not.
1242        let tries = [
1243            try_of(1, &[("E1", RedClass::Assertion)]),
1244            try_of(2, &[("E1", RedClass::BuildError)]),
1245        ];
1246        assert_eq!(decide_reproduce(&tries, &e1, 3), Reproduce::Retry);
1247    }
1248
1249    #[test]
1250    fn a_missing_special_case_withholds_verified() {
1251        let reproductions = vec![Reproduction {
1252            statement: "E1".to_owned(),
1253            file: "tests/repro.rs".to_owned(),
1254            test: "t".to_owned(),
1255            source: Source::Author,
1256            baseline: vec![RedClass::Assertion, RedClass::Assertion],
1257            digest: "sha256:x".to_owned(),
1258            argv: Vec::new(),
1259            final_: Final::Passed,
1260            revert: Revert::Rejected,
1261        }];
1262        let revert = RevertResult {
1263            outcome: Revert::Rejected,
1264            rejected_by: strings(&["E1"]),
1265        };
1266        let p2p = PassToPass {
1267            state: PassToPassState::NoneSelected,
1268            members: Vec::new(),
1269        };
1270        let d = Discrimination {
1271            reproductions: &reproductions,
1272            revert: &revert,
1273            special_case: &[],
1274            special_case_error: Some("the author call failed: timeout"),
1275        };
1276        let Verdict::Failed { reasons } =
1277            bug_verdict(Verdict::Verified, &d, &p2p, &HeldOut::Passed { files: 1 })
1278        else {
1279            panic!("verified without a special case");
1280        };
1281        assert!(
1282            reasons
1283                .iter()
1284                .any(|r| r.contains("E1") && r.contains("the author call failed: timeout")),
1285            "{reasons:?}"
1286        );
1287    }
1288
1289    #[test]
1290    fn a_slug_is_never_a_path() {
1291        for ok in ["empty-title-crash", "a", "0-x", "saving-an-empty-title-2"] {
1292            assert!(valid_slug(ok), "{ok}");
1293        }
1294        for bad in [
1295            "",
1296            "..",
1297            "../x",
1298            "a/b",
1299            "a\\b",
1300            "-x",
1301            "A",
1302            "a b",
1303            "a.b",
1304            "é",
1305            &"a".repeat(MAX_SLUG + 1),
1306        ] {
1307            assert!(!valid_slug(bad), "{bad}");
1308        }
1309    }
1310
1311    #[test]
1312    fn mixed_tries_name_what_was_satisfied() {
1313        let tries = [
1314            try_of(1, &[("E1", RedClass::Assertion), ("E2", RedClass::Passed)]),
1315            try_of(2, &[("E1", RedClass::BuildError), ("E2", RedClass::Passed)]),
1316        ];
1317        assert_eq!(
1318            decide_reproduce(&tries, &strings(&["E1", "E2"]), 2),
1319            Reproduce::CannotReproduce {
1320                reproduced: strings(&["E1"]),
1321                satisfied: strings(&["E2"]),
1322                not_demonstrated: Vec::new(),
1323            }
1324        );
1325        let passed = [
1326            try_of(1, &[("E1", RedClass::Passed)]),
1327            try_of(2, &[("E1", RedClass::Passed)]),
1328        ];
1329        assert_eq!(
1330            decide_reproduce(&passed, &strings(&["E1"]), 2),
1331            Reproduce::AlreadySatisfied
1332        );
1333        assert_eq!(
1334            decide_reproduce(&passed[..1], &strings(&["E1"]), 2),
1335            Reproduce::Retry
1336        );
1337    }
1338}