Skip to main content

speccoder_runtime/
strength.rs

1//! Measuring the acceptance set's strength: wrong and
2//! permitted candidates of a verified task's change, run against its
3//! acceptance tests.
4//!
5//! Every candidate is written into the task's worktree under a checkpoint —
6//! as held-out tests are overlaid — run with the visible acceptance command
7//! (and, for the combined set, each held-out file overlaid), and the tree is
8//! put back. Afterwards the tree and the judge inputs must be exactly as
9//! before, or the measurement is void. The rules are
10//! `speccoder_core::strength`'s; this module only runs things.
11
12use std::path::{Path, PathBuf};
13use std::time::Instant;
14
15use serde::{Deserialize, Serialize};
16use serde_json::{Value, json};
17use speccoder_core::strength::{
18    self as rules, AcceptanceStrength, Gate, Mutants, NearMiss, Outcome, Permitted,
19    PermittedOutcome, Ruling, Set, State,
20};
21use speccoder_core::verify::{CheckResult, CheckStatus, RedClass, RunnerSummary, classify_red};
22
23use crate::held_out::HeldOutFile;
24use crate::integrity::{JudgeSnapshot, sha256_hex};
25use crate::substrate::run_config::StrengthConfig;
26use crate::tools::policy::Policy;
27use crate::{mutation, sandbox, worktree};
28
29/// Where a task's variants, findings and rulings are kept.
30#[must_use]
31pub fn dir(plane_dir: &Path, feature: &str, task: &str) -> PathBuf {
32    plane_dir.join("strength").join(feature).join(task)
33}
34
35/// One candidate: files that replace changed source files.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Variant {
38    /// `wrong` or `permitted`.
39    pub kind: String,
40    /// The scenario it concerns.
41    pub scenario: String,
42    /// What it gets wrong, or the freedom it uses.
43    pub rationale: String,
44    /// `(path relative to the worktree, content)`.
45    pub files: Vec<(String, String)>,
46}
47
48impl Variant {
49    /// `sha256:<hex>` over its files: what a ruling is bound to.
50    #[must_use]
51    pub fn digest(&self) -> String {
52        let mut text = String::new();
53        for (p, c) in &self.files {
54            text.push_str(&format!("{p}\n{}\n{c}\n", c.len()));
55        }
56        format!("sha256:{}", sha256_hex(text.as_bytes()))
57    }
58}
59
60/// The author's answer, validated: only the changed source files, each a
61/// real change, no duplicates, at most `near_miss` wrong and `permitted`
62/// permitted. What was dropped and why goes to `notes`.
63#[must_use]
64pub fn validate(
65    answer: &Value,
66    changed: &[(String, String)],
67    config: &StrengthConfig,
68    notes: &mut Vec<String>,
69) -> (Vec<Variant>, Vec<Variant>) {
70    let mut wrong = Vec::new();
71    let mut permitted = Vec::new();
72    let mut seen = std::collections::BTreeSet::new();
73    for (i, v) in answer["variants"]
74        .as_array()
75        .into_iter()
76        .flatten()
77        .enumerate()
78    {
79        let kind = v["kind"].as_str().unwrap_or_default().to_owned();
80        let mut files = Vec::new();
81        let mut bad = None;
82        for f in v["files"].as_array().into_iter().flatten() {
83            let (Some(path), Some(content)) = (f["path"].as_str(), f["content"].as_str()) else {
84                bad = Some("a file without path or content".to_owned());
85                break;
86            };
87            let path = match crate::tools::paths::normalise(path) {
88                Ok(p) => p,
89                Err(e) => {
90                    bad = Some(format!("`{path}`: {}", e.0));
91                    break;
92                }
93            };
94            match changed.iter().find(|(p, _)| *p == path) {
95                None => {
96                    bad = Some(format!("`{path}` is not a changed source file"));
97                    break;
98                }
99                Some((_, now)) if now == content => {}
100                Some(_) => files.push((path, content.to_owned())),
101            }
102        }
103        if bad.is_none() && files.is_empty() {
104            bad = Some("identical to the change".to_owned());
105        }
106        let variant = Variant {
107            kind: kind.clone(),
108            scenario: v["scenario"].as_str().unwrap_or_default().to_owned(),
109            rationale: v["rationale"].as_str().unwrap_or_default().to_owned(),
110            files,
111        };
112        if bad.is_none() && !seen.insert(variant.digest()) {
113            bad = Some("the same as an earlier variant".to_owned());
114        }
115        if let Some(why) = bad {
116            notes.push(format!("variant {} dropped: {why}", i + 1));
117            continue;
118        }
119        match kind.as_str() {
120            "wrong" if wrong.len() < config.near_miss as usize => wrong.push(variant),
121            "permitted" if permitted.len() < config.permitted as usize => permitted.push(variant),
122            _ => notes.push(format!(
123                "variant {} dropped: more {kind} variants than asked for",
124                i + 1
125            )),
126        }
127    }
128    (wrong, permitted)
129}
130
131/// What one acceptance run says about a candidate.
132#[must_use]
133pub fn classify(result: &CheckResult, summary: Option<&RunnerSummary>) -> Outcome {
134    if result.status == CheckStatus::Passed {
135        return Outcome::Survived;
136    }
137    match classify_red(result, summary) {
138        RedClass::Assertion | RedClass::Absent => Outcome::Rejected,
139        RedClass::Timeout => Outcome::Timeout,
140        // A build or collection error, nothing collected, or output not
141        // recognised: not judged (never counted as strong).
142        _ => Outcome::Unviable,
143    }
144}
145
146/// How a candidate fared against the visible and the combined set, and the
147/// failing run's tail.
148#[derive(Debug, Clone)]
149struct Ran {
150    visible: Outcome,
151    combined: Outcome,
152    failing: Option<String>,
153}
154
155fn tail(r: &CheckResult) -> String {
156    let text = if r.stdout_tail.trim().is_empty() {
157        &r.stderr_tail
158    } else {
159        &r.stdout_tail
160    };
161    let lines: Vec<&str> = text.lines().collect();
162    lines[lines.len().saturating_sub(12)..].join("\n")
163}
164
165/// What measuring needs.
166pub struct Setup<'a> {
167    /// The task's worktree.
168    pub wt_root: &'a Path,
169    /// The project inside it.
170    pub project: &'a Path,
171    /// Scratch for checkpoints.
172    pub tmp: &'a Path,
173    /// Sandboxed commands.
174    pub runner: &'a sandbox::Runner,
175    /// The judge's inputs, checked unchanged afterwards.
176    pub policy: &'a Policy,
177    /// The visible acceptance commands (the first is the original set's;
178    /// mutants run with it).
179    pub acceptance: &'a [Vec<String>],
180    /// The held-out files, verified, with their contents.
181    pub held_out: &'a [(HeldOutFile, String)],
182    /// `[mutation]`, when configured.
183    pub mutation: Option<&'a mutation::Config>,
184    /// The attempt's change.
185    pub patch: &'a str,
186    /// `[acceptance]` strength settings.
187    pub config: &'a StrengthConfig,
188    /// Where variants and findings are kept.
189    pub keep: &'a Path,
190    /// Rulings a person made on this task.
191    pub rulings: &'a [Ruling],
192}
193
194/// Writes `files` into the worktree.
195fn apply(root: &Path, files: &[(String, String)]) -> Result<(), String> {
196    for (p, c) in files {
197        std::fs::write(root.join(p), c).map_err(|e| format!("{p}: {e}"))?;
198    }
199    Ok(())
200}
201
202/// Runs the acceptance set against the tree as it is now.
203fn judge_tree(s: &Setup<'_>, assertion_only: bool) -> Result<Ran, String> {
204    // Every visible command; the gravest outcome stands.
205    let rank = |o: Outcome| match o {
206        Outcome::Rejected => 3,
207        Outcome::Timeout => 2,
208        Outcome::Unviable => 1,
209        Outcome::Survived => 0,
210    };
211    let mut visible = Outcome::Survived;
212    let mut failing = None;
213    for argv in s.acceptance {
214        let (r, summary) = crate::pipeline::acceptance_run(s.project, argv, s.runner);
215        let o = classify(&r, summary.as_ref());
216        if rank(o) > rank(visible) {
217            visible = o;
218            failing = Some(tail(&r));
219        }
220    }
221    let mut combined = visible;
222    // The held-out set only matters when the visible one let it through.
223    if visible == Outcome::Survived && !s.held_out.is_empty() {
224        let runs = crate::held_out::overlay_run(
225            s.wt_root, s.project, s.tmp, s.held_out, s.runner, s.policy,
226        )?;
227        for ran in runs.iter().filter_map(|r| r.result.as_ref()) {
228            let o = classify(&ran.0, ran.1.as_ref());
229            if o == Outcome::Rejected || (o == Outcome::Timeout && !assertion_only) {
230                combined = o;
231                failing = Some(tail(&ran.0));
232                break;
233            }
234        }
235    }
236    Ok(Ran {
237        visible,
238        combined,
239        failing,
240    })
241}
242
243/// A unified diff of a variant against the change, for a person.
244fn variant_patch(changed: &[(String, String)], v: &Variant) -> String {
245    let mut out = format!("# {} — {}: {}\n", v.kind, v.scenario, v.rationale);
246    for (path, content) in &v.files {
247        let now = changed
248            .iter()
249            .find(|(p, _)| p == path)
250            .map(|(_, c)| c.as_str())
251            .unwrap_or_default();
252        out.push_str(
253            &similar::TextDiff::from_lines(now, content)
254                .unified_diff()
255                .header(&format!("a/{path}"), &format!("b/{path}"))
256                .to_string(),
257        );
258    }
259    out
260}
261
262/// The changed source files with their final contents.
263#[must_use]
264pub fn changed_files(wt_root: &Path, patch: &str) -> Vec<(String, String)> {
265    mutation::changed_sources(patch)
266        .into_iter()
267        .filter_map(|p| {
268            std::fs::read_to_string(wt_root.join(&p))
269                .ok()
270                .map(|c| (p, c))
271        })
272        .collect()
273}
274
275/// What [`measure`] needs from the author.
276pub struct Authored {
277    /// The answer (`{variants, notes}`), or why there is none.
278    pub answer: Result<Value, String>,
279    /// The author's run.
280    pub run_id: Option<String>,
281    /// Its tokens.
282    pub usage: Option<Value>,
283}
284
285/// Measures the set. `author` is asked for variants (after the mutants, so a
286/// slow tool leaves the call for last only when there is budget). `Err`: the
287/// tree or the judge inputs did not come back as they were, and the
288/// measurement is void.
289pub fn measure(
290    s: &Setup<'_>,
291    author: impl FnOnce(&[(String, String)]) -> Authored,
292) -> Result<AcceptanceStrength, String> {
293    let began = Instant::now();
294    let deadline = began + s.config.timeout;
295    let changed = changed_files(s.wt_root, s.patch);
296    let held = !s.held_out.is_empty();
297    let mut notes = Vec::new();
298    let done =
299        |visible, combined, mutants, near_misses, permitted, rulings, notes, run_id, usage| {
300            AcceptanceStrength {
301                visible,
302                combined,
303                held_out: held,
304                mutants,
305                near_misses,
306                permitted,
307                rulings,
308                gate: Gate::Off,
309                notes,
310                run_id,
311                usage,
312                duration_ms: u64::try_from(began.elapsed().as_millis()).unwrap_or(u64::MAX),
313            }
314        };
315    if changed.is_empty() {
316        return Ok(done(
317            rules::unmeasured(State::NotApplicable),
318            rules::unmeasured(State::NotApplicable),
319            None,
320            Vec::new(),
321            Vec::new(),
322            Vec::new(),
323            vec!["no source change".to_owned()],
324            None,
325            None,
326        ));
327    }
328    let base = worktree::checkpoint(s.wt_root, s.tmp).map_err(|e| e.0)?;
329    let judged = JudgeSnapshot::take(s.wt_root, s.policy);
330    let mut partial = false;
331
332    // 1. Mutants of the changed code, judged by the acceptance command alone.
333    let mutants = match s.mutation {
334        None => {
335            notes.push("mutants not measured: no [mutation] tool".to_owned());
336            None
337        }
338        Some(m) => {
339            let mut m = m.clone();
340            m.timeout = m
341                .timeout
342                .min(deadline.saturating_duration_since(Instant::now()));
343            let scratch = s.runner.scratch().unwrap_or_else(|| s.tmp.to_path_buf());
344            let first = s.acceptance.first().map(Vec::as_slice).unwrap_or_default();
345            match mutation::run_against(&m, s.project, s.patch, &scratch, s.runner, first) {
346                Some(r) if r.get("error").is_none() => {
347                    let n = |k: &str| u32::try_from(r[k].as_u64().unwrap_or(0)).unwrap_or(u32::MAX);
348                    Some(Mutants {
349                        tool: r["tool"].as_str().unwrap_or_default().to_owned(),
350                        caught: n("caught"),
351                        missed: n("missed"),
352                        timeout: n("timeout"),
353                        unviable: n("unviable"),
354                        survivors: r["survivors"]
355                            .as_array()
356                            .into_iter()
357                            .flatten()
358                            .filter_map(|v| v.as_str().map(str::to_owned))
359                            .collect(),
360                    })
361                }
362                Some(r) => {
363                    notes.push(format!(
364                        "mutants not measured: {}",
365                        r["error"].as_str().unwrap_or("the tool failed")
366                    ));
367                    None
368                }
369                None => None,
370            }
371        }
372    };
373
374    // 2. The author's variants.
375    let (mut run_id, mut usage) = (None, None);
376    let (wrong, allowed) = if s.config.near_miss + s.config.permitted == 0 {
377        (Vec::new(), Vec::new())
378    } else if Instant::now() >= deadline {
379        partial = true;
380        notes.push("near misses not measured: the strength budget ran out".to_owned());
381        (Vec::new(), Vec::new())
382    } else {
383        let a = author(&changed);
384        run_id = a.run_id;
385        usage = a.usage;
386        match a.answer {
387            Ok(answer) => validate(&answer, &changed, s.config, &mut notes),
388            Err(e) => {
389                notes.push(format!("near misses not measured: {e}"));
390                (Vec::new(), Vec::new())
391            }
392        }
393    };
394    let _ = std::fs::create_dir_all(s.keep);
395
396    // 3. Each variant, under the checkpoint.
397    let mut run_one = |v: &Variant, assertion_only: bool| -> Result<Option<Ran>, String> {
398        if Instant::now() >= deadline {
399            partial = true;
400            return Ok(None);
401        }
402        let ran = apply(s.wt_root, &v.files).and_then(|()| judge_tree(s, assertion_only));
403        worktree::restore(s.wt_root, s.tmp, &base).map_err(|e| e.0)?;
404        ran.map(Some)
405    };
406    let mut near_misses = Vec::new();
407    for (i, v) in wrong.iter().enumerate() {
408        let Some(ran) = run_one(v, false)? else { break };
409        near_misses.push(NearMiss {
410            id: format!("near-miss-{}", i + 1),
411            scenario: v.scenario.clone(),
412            rationale: v.rationale.clone(),
413            visible: ran.visible,
414            combined: ran.combined,
415        });
416        if ran.combined == Outcome::Survived {
417            let _ = std::fs::write(
418                s.keep.join(format!("near-miss-{}.patch", i + 1)),
419                variant_patch(&changed, v),
420            );
421        }
422    }
423    let mut permitted = Vec::new();
424    let mut rulings = Vec::new();
425    for (i, v) in allowed.iter().enumerate() {
426        let Some(ran) = run_one(v, true)? else { break };
427        let digest = v.digest();
428        let id = format!("permitted-{}", i + 1);
429        let mut outcome = match ran.combined {
430            Outcome::Survived => PermittedOutcome::Accepted,
431            Outcome::Rejected => PermittedOutcome::Rejected,
432            Outcome::Timeout => PermittedOutcome::Timeout,
433            Outcome::Unviable => PermittedOutcome::Unviable,
434        };
435        let mut patch = None;
436        if outcome == PermittedOutcome::Rejected {
437            if let Some(r) = s.rulings.iter().find(|r| r.digest == digest) {
438                outcome = PermittedOutcome::Ruled;
439                rulings.push(r.clone());
440            } else {
441                let path = s.keep.join(format!("{id}.patch"));
442                let _ = std::fs::write(&path, variant_patch(&changed, v));
443                patch = Some(path.to_string_lossy().into_owned());
444            }
445        }
446        permitted.push(Permitted {
447            id,
448            scenario: v.scenario.clone(),
449            rationale: v.rationale.clone(),
450            digest,
451            outcome,
452            failing: matches!(
453                outcome,
454                PermittedOutcome::Rejected | PermittedOutcome::Ruled
455            )
456            .then_some(ran.failing)
457            .flatten(),
458            patch,
459        });
460    }
461    if partial {
462        notes.push("the strength budget ran out: partial".to_owned());
463    }
464
465    // 4. The tree and the judge's inputs are exactly as before.
466    let after = worktree::checkpoint(s.wt_root, s.tmp).map_err(|e| e.0)?;
467    if after != base {
468        return Err("measuring acceptance strength left changes in the worktree".to_owned());
469    }
470    if let Some(d) = judged
471        .drift(
472            &JudgeSnapshot::take(s.wt_root, s.policy),
473            s.policy,
474            &std::collections::BTreeSet::new(),
475        )
476        .first()
477    {
478        return Err(format!(
479            "measuring acceptance strength changed judge input {}",
480            d.path
481        ));
482    }
483    let visible = rules::strength(mutants.as_ref(), &near_misses, Set::Visible, partial);
484    let combined = rules::strength(mutants.as_ref(), &near_misses, Set::Combined, partial);
485    Ok(done(
486        visible,
487        combined,
488        mutants,
489        near_misses,
490        permitted,
491        rulings,
492        notes,
493        run_id,
494        usage,
495    ))
496}
497
498/// The input part `weak` for a regeneration: what got through, described
499/// to the test author (never to the implementer).
500#[must_use]
501pub fn weak(a: &AcceptanceStrength) -> Value {
502    let mut out: Vec<Value> = a
503        .near_misses
504        .iter()
505        .filter(|n| n.combined == Outcome::Survived)
506        .map(|n| json!({ "source": "near_miss", "scenario": n.scenario, "wrong": n.rationale }))
507        .collect();
508    if let Some(m) = &a.mutants {
509        out.extend(
510            m.survivors
511                .iter()
512                .take(12)
513                .map(|s| json!({ "source": "mutant", "wrong": s })),
514        );
515    }
516    json!(out)
517}
518
519/// A ruling a person made (`task rule --tests-right`).
520#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
521pub struct Rulings {
522    /// Every ruling, oldest first.
523    pub rulings: Vec<Ruling>,
524}
525
526const RULINGS: &str = "rulings.json";
527const HELD: &str = "held.json";
528
529/// The task's rulings.
530#[must_use]
531pub fn rulings(keep: &Path) -> Vec<Ruling> {
532    std::fs::read_to_string(keep.join(RULINGS))
533        .ok()
534        .and_then(|t| serde_json::from_str::<Rulings>(&t).ok())
535        .map(|r| r.rulings)
536        .unwrap_or_default()
537}
538
539/// Records `ruling`.
540pub fn add_ruling(keep: &Path, ruling: Ruling) -> std::io::Result<()> {
541    let mut all = rulings(keep);
542    all.retain(|r| r.digest != ruling.digest);
543    all.push(ruling);
544    std::fs::create_dir_all(keep)?;
545    std::fs::write(
546        keep.join(RULINGS),
547        serde_json::to_vec_pretty(&Rulings { rulings: all }).map_err(std::io::Error::other)?,
548    )
549}
550
551/// An attempt held for a ruling: what a person decides on.
552#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
553pub struct Held {
554    /// The flagged alternatives.
555    pub findings: Vec<Permitted>,
556}
557
558/// Holds the attempt for a person.
559pub fn hold(keep: &Path, findings: &[&Permitted]) -> std::io::Result<()> {
560    std::fs::create_dir_all(keep)?;
561    std::fs::write(
562        keep.join(HELD),
563        serde_json::to_vec_pretty(&Held {
564            findings: findings.iter().map(|p| (*p).clone()).collect(),
565        })
566        .map_err(std::io::Error::other)?,
567    )
568}
569
570/// What a held attempt waits on, if it is held.
571#[must_use]
572pub fn held(keep: &Path) -> Option<Held> {
573    serde_json::from_str(&std::fs::read_to_string(keep.join(HELD)).ok()?).ok()
574}
575
576/// Releases a held attempt (ticked, set aside, or re-judged).
577pub fn release(keep: &Path) {
578    let _ = std::fs::remove_file(keep.join(HELD));
579}
580
581/// Another task of `feature` whose attempt is held for a ruling: it holds
582/// the worktree, so `task` must wait.
583#[must_use]
584pub fn held_elsewhere(plane_dir: &Path, feature: &str, task: &str) -> Option<String> {
585    std::fs::read_dir(plane_dir.join("strength").join(feature))
586        .ok()?
587        .flatten()
588        .map(|e| e.file_name().to_string_lossy().into_owned())
589        .filter(|t| t != task)
590        .find(|t| held(&dir(plane_dir, feature, t)).is_some())
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    fn changed() -> Vec<(String, String)> {
598        vec![(
599            "src/lib.rs".to_owned(),
600            "pub fn add(a: i32, b: i32) -> i32 { a + b }\n".to_owned(),
601        )]
602    }
603
604    #[test]
605    fn only_real_changes_of_changed_files_are_kept() {
606        let answer = json!({ "variants": [
607            { "kind": "wrong", "scenario": "US1-1", "rationale": "off by one", "files": [{ "path": "src/lib.rs", "content": "pub fn add(a: i32, b: i32) -> i32 { a + b + 1 }\n" }] },
608            { "kind": "wrong", "scenario": "US1-1", "rationale": "same again", "files": [{ "path": "src/lib.rs", "content": "pub fn add(a: i32, b: i32) -> i32 { a + b + 1 }\n" }] },
609            { "kind": "wrong", "scenario": "US1-2", "rationale": "elsewhere", "files": [{ "path": "src/other.rs", "content": "x" }] },
610            { "kind": "wrong", "scenario": "US1-2", "rationale": "no change", "files": [{ "path": "src/lib.rs", "content": "pub fn add(a: i32, b: i32) -> i32 { a + b }\n" }] },
611            { "kind": "permitted", "scenario": "US1-3", "rationale": "commutes", "files": [{ "path": "./src/lib.rs", "content": "pub fn add(a: i32, b: i32) -> i32 { b + a }\n" }] },
612            { "kind": "permitted", "scenario": "US1-3", "rationale": "too many", "files": [{ "path": "src/lib.rs", "content": "pub fn add(a: i32, b: i32) -> i32 { b.wrapping_add(a) }\n" }] },
613        ], "notes": [] });
614        let mut notes = Vec::new();
615        let (wrong, permitted) =
616            validate(&answer, &changed(), &StrengthConfig::default(), &mut notes);
617        assert_eq!(wrong.len(), 1);
618        assert_eq!(permitted.len(), 1);
619        assert_eq!(permitted[0].files[0].0, "src/lib.rs");
620        let text = notes.join("\n");
621        for why in [
622            "same as an earlier",
623            "not a changed source file",
624            "identical to the change",
625            "more permitted",
626        ] {
627            assert!(text.contains(why), "{why}: {text}");
628        }
629        assert!(wrong[0].digest().starts_with("sha256:"));
630        assert_ne!(wrong[0].digest(), permitted[0].digest());
631    }
632
633    #[test]
634    fn a_pass_survives_an_assertion_rejects_a_build_error_is_unviable() {
635        let result = |status, stdout: &str| CheckResult {
636            name: "acceptance".to_owned(),
637            argv: vec!["cargo".to_owned(), "test".to_owned()],
638            status,
639            exit_code: Some(i32::from(status != CheckStatus::Passed)),
640            duration_ms: 1,
641            junit: false,
642            tests: None,
643            stdout_tail: stdout.to_owned(),
644            stderr_tail: String::new(),
645            truncated: false,
646            note: None,
647            flaky: false,
648        };
649        assert_eq!(
650            classify(&result(CheckStatus::Passed, ""), None),
651            Outcome::Survived
652        );
653        assert_eq!(
654            classify(&result(CheckStatus::TimedOut, ""), None),
655            Outcome::Timeout
656        );
657        let failed = RunnerSummary {
658            passed: 1,
659            failed: 1,
660            errors: 0,
661            build_failed: false,
662            unresolved_only: false,
663        };
664        assert_eq!(
665            classify(
666                &result(CheckStatus::Failed, "test result: FAILED"),
667                Some(&failed)
668            ),
669            Outcome::Rejected
670        );
671        assert_eq!(
672            classify(
673                &result(
674                    CheckStatus::Failed,
675                    "error[E0308]: mismatched types\nerror: could not compile `x`"
676                ),
677                None
678            ),
679            Outcome::Unviable
680        );
681    }
682
683    #[test]
684    fn rulings_and_holds_are_kept_per_task() {
685        let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
686        let keep = dir(state.path(), "001-x", "T005");
687        let ruling = Ruling {
688            digest: "sha256:ab".to_owned(),
689            scenario: "US2-1".to_owned(),
690            by: "Dana".to_owned(),
691            reason: "the plan fixes the order".to_owned(),
692        };
693        add_ruling(&keep, ruling.clone()).unwrap_or_else(|e| panic!("{e}"));
694        add_ruling(&keep, ruling.clone()).unwrap_or_else(|e| panic!("{e}"));
695        assert_eq!(rulings(&keep), [ruling]);
696        let p = Permitted {
697            id: "permitted-1".to_owned(),
698            scenario: "US2-1".to_owned(),
699            rationale: "another order".to_owned(),
700            digest: "sha256:ab".to_owned(),
701            outcome: PermittedOutcome::Rejected,
702            failing: None,
703            patch: None,
704        };
705        hold(&keep, &[&p]).unwrap_or_else(|e| panic!("{e}"));
706        assert_eq!(
707            held_elsewhere(state.path(), "001-x", "T006").as_deref(),
708            Some("T005")
709        );
710        assert!(held_elsewhere(state.path(), "001-x", "T005").is_none());
711        release(&keep);
712        assert!(held(&keep).is_none());
713    }
714}