Skip to main content

speccoder_runtime/bug/
mod.rs

1//! `speccoder bug`: the L2 rung. The person's triad is recorded where the
2//! format keeps bugs (its own bug structure first, else its standing
3//! bug-fix unit), reproduced by a test-blind author — one test per stated
4//! behaviour, red on the untouched code by an assertion — confirmed by the
5//! person once, frozen, and fixed as an ordinary task whose verdict also
6//! needs the revert rejected. Nothing here is a second pipeline: the
7//! stages run inside `speccoder run` (`pipeline::bug`).
8
9pub mod candidate;
10pub mod confirm;
11pub mod handoff;
12pub mod keep;
13pub mod named;
14pub mod state;
15pub mod unchanged;
16
17pub use keep::{keep, keep_with};
18
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21
22use agentplane::model::ModelProvider;
23use speccoder_core::actions::Via;
24use speccoder_core::bug::{
25    BugHome, BugReport, Final, Outcome, Reproduction, Revert, Source, Stage, Triad,
26};
27use speccoder_core::ladder::Origin;
28use speccoder_core::{CommandData, CommandResult, CommandStatus};
29
30use crate::RunOptions;
31use crate::format::BugRefusal;
32use crate::integrity::sha256_hex;
33use crate::progress::{BugEvent, Event};
34
35/// What the person asks for.
36#[derive(Debug, Clone, Default)]
37pub struct Request {
38    /// What happens (the symptom).
39    pub symptom: String,
40    /// What should happen instead.
41    pub expected: Vec<String>,
42    /// What must keep working.
43    pub unchanged: Vec<String>,
44    /// The issue it comes from.
45    pub issue: Option<u64>,
46    /// Their own reproduction tests, one per expected statement.
47    pub tests: Vec<String>,
48    /// Someone else's fix to judge.
49    pub candidate: Option<String>,
50    /// Continue a bug of the format's own bug structure.
51    pub slug: Option<String>,
52    /// Reproduce it once recorded.
53    pub run: bool,
54    /// The GitHub to read the issue or pull request from (default: the
55    /// environment's).
56    pub github: Option<crate::github::Ctx>,
57    /// The Ask conversation that suggested it.
58    pub asked: Option<String>,
59}
60
61impl Request {
62    /// The request a [`speccoder_core::actions::Runnable::BugFix`] carries.
63    #[must_use]
64    #[allow(clippy::too_many_arguments)]
65    pub fn of(
66        symptom: &str,
67        expected: &[String],
68        unchanged: &[String],
69        issue: Option<u64>,
70        tests: &[String],
71        candidate: Option<&str>,
72        slug: Option<&str>,
73        run: bool,
74    ) -> Self {
75        Self {
76            symptom: symptom.to_owned(),
77            expected: expected.to_vec(),
78            unchanged: unchanged.to_vec(),
79            issue,
80            tests: tests.to_vec(),
81            candidate: candidate.map(str::to_owned),
82            slug: slug.map(str::to_owned),
83            run,
84            github: None,
85            asked: None,
86        }
87    }
88}
89
90fn refuse(r: &BugRefusal) -> CommandResult {
91    let status =
92        speccoder_core::codes::lookup(r.code).map_or(CommandStatus::UsageError, |e| e.status);
93    CommandResult::new("bug", status, &r.message).with_code(r.code)
94}
95
96fn result(status: CommandStatus, message: &str, bug: &BugReport) -> CommandResult {
97    let mut r = CommandResult::new("bug", status, message)
98        .with_next(handoff::next(bug, bug.outcome))
99        .with_data(CommandData::Bug(Box::new(bug.clone())));
100    if let Some(code) = bug.code.as_deref() {
101        r = r.with_code(code);
102    }
103    r
104}
105
106fn emit(options: &RunOptions, task: &str, step: BugEvent) {
107    if let Some(p) = &options.progress {
108        p.event(&Event::Bug {
109            task: task.to_owned(),
110            step,
111        });
112    }
113}
114
115/// The state directory: `state`, else the environment's.
116fn state_or_env(state: Option<&Path>) -> Result<PathBuf, Box<CommandResult>> {
117    state
118        .map(Path::to_path_buf)
119        .or_else(crate::worktree::state_dir)
120        .ok_or_else(|| {
121            Box::new(
122                CommandResult::new(
123                    "bug",
124                    CommandStatus::ConfigError,
125                    "no state directory (set SPECCODER_STATE_DIR or HOME)",
126                )
127                .with_code("NO-STATE-DIR"),
128            )
129        })
130}
131
132/// `f` with the configured model and `state` (else the environment's).
133fn with_model(
134    path: &Path,
135    state: Option<&Path>,
136    f: impl FnOnce(&Path, Arc<dyn ModelProvider>, &str) -> CommandResult,
137) -> CommandResult {
138    let state = match state_or_env(state) {
139        Ok(s) => s,
140        Err(r) => return *r,
141    };
142    let model = match crate::pipeline::setup(path, None, &state, "bug") {
143        Ok(s) => s.model,
144        Err(r) => return *r,
145    };
146    match crate::substrate::model_config::open_driver(&model) {
147        Ok((provider, key)) => f(&state, provider, &key.unwrap_or_default()),
148        Err(e) => crate::substrate::model_config::refused("bug", &e),
149    }
150}
151
152/// Whether recording or running needs the model.
153fn needs_model(path: &Path, req: &Request) -> bool {
154    if req.run {
155        return true;
156    }
157    let format = crate::format::at(path);
158    format
159        .detect(path)
160        .and_then(|root| format.bug_unit(&root).ok())
161        .is_some_and(|h| h.kind.native() && req.slug.is_none())
162}
163
164/// `speccoder bug …` with the configured model.
165#[must_use]
166pub fn bug(
167    path: &Path,
168    req: &Request,
169    via: Via,
170    state: Option<&Path>,
171    options: &RunOptions,
172) -> CommandResult {
173    // What is refused is refused before a model is opened.
174    if req.slug.is_none()
175        && let Err(e) = Triad::new(&req.symptom, &req.expected, &req.unchanged, sha256_hex)
176    {
177        return refuse(&BugRefusal::new(e.code(), e.message()));
178    }
179    if needs_model(path, req) {
180        return with_model(path, state, |state, provider, key| {
181            bug_with(path, req, via, state, Some(provider), Some(key), options)
182        });
183    }
184    match state_or_env(state) {
185        Ok(state) => bug_with(path, req, via, &state, None, None, options),
186        Err(r) => *r,
187    }
188}
189
190/// The project root at `path`, or why there is none (for Kiro: not yet supported).
191fn root_of(
192    path: &Path,
193) -> Result<(PathBuf, &'static dyn crate::format::Adapter), Box<CommandResult>> {
194    match crate::format::open(path, None) {
195        Ok(o) => Ok((PathBuf::from(&o.snapshot.root), o.adapter)),
196        Err(_) if let Some(r) = crate::format::bug_refusal_unsupported(path) => {
197            Err(Box::new(refuse(&r)))
198        }
199        Err(r) => Err(Box::new(CommandResult::new("bug", r.status, &r.message))),
200    }
201}
202
203/// `[bug]` settings of the project at `root`.
204fn settings(root: &Path) -> crate::substrate::run_config::RunConfig {
205    crate::verify::find_config(root)
206        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
207        .and_then(|t| crate::substrate::run_config::parse(&t).ok())
208        .unwrap_or_default()
209}
210
211/// How `read` (the format's file) differs from the person's `triad`: each
212/// statement of theirs it lacks, and each it carries that they did not give
213/// (an extra line, a longer or reworded one). Empty: exactly their words
214/// (whitespace-normalised), the same statements in the same order.
215fn drift(triad: &Triad, read: &crate::format::ReadBug) -> Vec<String> {
216    let norm = |v: &[String]| -> Vec<String> {
217        v.iter()
218            .map(|x| speccoder_core::bug::normalise(x))
219            .collect()
220    };
221    let side = |label: &str, given: Vec<String>, found: Vec<String>| -> Vec<String> {
222        let mut out: Vec<String> = given
223            .iter()
224            .filter(|g| !found.contains(g))
225            .map(|g| format!("missing {label}: {g}"))
226            .chain(
227                found
228                    .iter()
229                    .filter(|f| !given.contains(f))
230                    .map(|f| format!("not yours {label}: {f}")),
231            )
232            .collect();
233        if out.is_empty() && given != found {
234            out.push(format!(
235                "{label}: {} statement(s) given, {} read back (another order or a repeat)",
236                given.len(),
237                found.len()
238            ));
239        }
240        out
241    };
242    let mut out = side(
243        "Current",
244        vec![triad.current.text.clone()],
245        norm(&read.current),
246    );
247    out.extend(side(
248        "Expected",
249        triad.expected.iter().map(|s| s.text.clone()).collect(),
250        norm(&read.expected),
251    ));
252    out.extend(side(
253        "Unchanged",
254        triad.unchanged.iter().map(|s| s.text.clone()).collect(),
255        norm(&read.unchanged),
256    ));
257    out
258}
259
260fn triad_of(read: &crate::format::ReadBug) -> Result<Triad, BugRefusal> {
261    Triad::new(
262        read.current.first().map_or("", String::as_str),
263        &read.expected,
264        &read.unchanged,
265        sha256_hex,
266    )
267    .map_err(|e| BugRefusal::new(e.code(), e.message()))
268}
269
270/// [`bug()`] against `state` with `provider` (needed to record in a format's
271/// own bug structure and to run).
272#[allow(clippy::too_many_lines)] // one linear procedure, in reading order
273#[must_use]
274pub fn bug_with(
275    path: &Path,
276    req: &Request,
277    via: Via,
278    state: &Path,
279    provider: Option<Arc<dyn ModelProvider>>,
280    secret: Option<&str>,
281    options: &RunOptions,
282) -> CommandResult {
283    let (root, format) = match root_of(path) {
284        Ok(x) => x,
285        Err(r) => return *r,
286    };
287    let plane = crate::worktree::project_state_dir(state, &root);
288    let config = settings(&root);
289    let home = match format.bug_unit(&root) {
290        Ok(h) => h,
291        Err(r) => return refuse(&r),
292    };
293    let by = crate::approvals::who_in(&root);
294
295    // Continue a bug the format's own bug step created.
296    if let Some(slug) = req.slug.as_deref() {
297        if !speccoder_core::bug::valid_slug(slug) {
298            return invalid_slug(slug);
299        }
300        let Some((dir, unit)) = format.bug_dir(&home, slug) else {
301            return CommandResult::new(
302                "bug",
303                CommandStatus::UsageError,
304                &format!("--slug names a bug of the format's own bug structure; this project's bugs live in {}", home.location),
305            )
306            .with_code("USAGE");
307        };
308        let task = format!("bug:{slug}");
309        if let Some(existing) = state::load(&plane, &unit, &task) {
310            return if req.run {
311                run_bug(&root, &existing, state, provider, secret, options)
312            } else {
313                result(
314                    CommandStatus::Ok,
315                    &format!(
316                        "{} is recorded; run it: speccoder bug run --slug {slug}",
317                        existing.key()
318                    ),
319                    &existing,
320                )
321            };
322        }
323        let read = match format.read_bug(&root, &home, slug) {
324            Ok(r) => r,
325            Err(r) => return refuse(&r),
326        };
327        let triad = match triad_of(&read) {
328            Ok(t) => t,
329            Err(r) => return refuse(&BugRefusal::new("BUG-ASSESSMENT-UNREADABLE", r.message)),
330        };
331        if !req.expected.is_empty() {
332            match Triad::new(&req.symptom, &req.expected, &req.unchanged, sha256_hex) {
333                Ok(given) => {
334                    let missing = drift(&given, &read);
335                    if !missing.is_empty() {
336                        return refuse(&BugRefusal::new(
337                            "BUG-ASSESSMENT-DRIFT",
338                            format!(
339                                "{} does not carry exactly your words: {}",
340                                read.file,
341                                missing.join("; ")
342                            ),
343                        ));
344                    }
345                }
346                Err(e) => return refuse(&BugRefusal::new(e.code(), e.message())),
347            }
348        }
349        let home = BugHome {
350            location: dir,
351            ..home
352        };
353        let title = read.title.clone().unwrap_or_else(|| triad.title());
354        let bug = new_report(
355            home,
356            unit,
357            task.clone(),
358            task,
359            title,
360            triad,
361            config.reproduce_tries,
362            None,
363        );
364        return finish_record(
365            &root,
366            &plane,
367            bug,
368            Vec::new(),
369            state::Extra {
370                by: Some(by),
371                via: Some(via),
372                asked: req.asked.clone(),
373                ..state::Extra::default()
374            },
375            via,
376            state,
377            provider,
378            secret,
379            req.run,
380            options,
381        );
382    }
383
384    // The person's words, checked before anything is written.
385    let triad = match Triad::new(&req.symptom, &req.expected, &req.unchanged, sha256_hex) {
386        Ok(t) => t,
387        Err(e) => return refuse(&BugRefusal::new(e.code(), e.message())),
388    };
389    let mut person: Vec<(Reproduction, String)> = Vec::new();
390    if !req.tests.is_empty() {
391        if req.tests.len() != triad.expected.len() {
392            return refuse(&BugRefusal::new(
393                "BUG-TEST-COVERAGE",
394                format!(
395                    "{} --test for {} --expected statement(s): one test per stated behaviour, in order",
396                    req.tests.len(),
397                    triad.expected.len()
398                ),
399            ));
400        }
401        let runner = match crate::sandbox::runner_at(&root) {
402            Ok((r, _)) => r,
403            Err(e) => return CommandResult::new("bug", CommandStatus::ConfigError, &e),
404        };
405        for (spec, statement) in req.tests.iter().zip(&triad.expected) {
406            let n = match named::resolve(&root, spec, config.bug_test.as_deref()) {
407                Ok(n) => n,
408                Err(r) => return refuse(&r),
409            };
410            let (class, _) = named::baseline(&root, &n.argv, &runner);
411            if class != speccoder_core::verify::RedClass::Assertion {
412                return refuse(&named::not_red(spec, class));
413            }
414            person.push((
415                Reproduction {
416                    statement: statement.id.clone(),
417                    file: n.file,
418                    test: n.name,
419                    source: Source::Person,
420                    baseline: vec![class, class],
421                    digest: format!("sha256:{}", sha256_hex(n.content.as_bytes())),
422                    argv: n.argv,
423                    final_: Final::NotRun,
424                    revert: Revert::NotRun,
425                },
426                n.content,
427            ));
428        }
429    }
430    let mut extra = state::Extra {
431        by: Some(by.clone()),
432        via: Some(via),
433        asked: req.asked.clone(),
434        ..state::Extra::default()
435    };
436    let mut candidate = None;
437    if let Some(spec) = req.candidate.as_deref() {
438        let Some(c) = candidate::parse(spec) else {
439            return CommandResult::new(
440                "bug",
441                CommandStatus::UsageError,
442                "--candidate takes a patch file or pr:N",
443            )
444            .with_code("USAGE");
445        };
446        let (diff, head) = match &c {
447            candidate::Spec::Patch(p) => match candidate::read_patch(&root, path, p, via) {
448                Ok(d) => (d, None),
449                Err(e) => {
450                    return CommandResult::new("bug", CommandStatus::UsageError, &e)
451                        .with_code("USAGE");
452                }
453            },
454            candidate::Spec::Pr(n) => match crate::github::pull_diff(
455                &req.github
456                    .clone()
457                    .unwrap_or_else(crate::github::Ctx::from_env),
458                &root,
459                *n,
460            ) {
461                Ok((d, head)) => (d, Some(head)),
462                Err(e) => {
463                    return CommandResult::new(
464                        "bug",
465                        CommandStatus::Error,
466                        &format!("pull request #{n}: {e}"),
467                    )
468                    .with_code("GITHUB-FAILED");
469                }
470            },
471        };
472        let policy = crate::tools::policy::Policy::new(&[], &[]).rules(format.rules());
473        let touched = match candidate::touched(&repo_prefix(&root).0, &diff) {
474            Ok(t) => t,
475            Err(r) => {
476                return refuse(&BugRefusal::new(
477                    "BUG-CANDIDATE-APPLY",
478                    format!(
479                        "the candidate cannot be read as a patch: {}",
480                        r.paths.join("; ")
481                    ),
482                ));
483            }
484        };
485        let judged = candidate::judge_paths(&touched, &policy, &[], &[]);
486        if !judged.is_empty() {
487            return refuse(&BugRefusal::new(
488                "BUG-CANDIDATE-JUDGE",
489                format!(
490                    "the candidate touches what judges it: {}",
491                    judged.join(", ")
492                ),
493            ));
494        }
495        candidate = Some(candidate::record(&c, &diff, head));
496        extra.candidate_patch = Some(diff);
497    }
498    let mut issue_ref = None;
499    if let Some(n) = req.issue {
500        let repo = crate::github::slug_of(&root);
501        match crate::github::issue(
502            &req.github
503                .clone()
504                .unwrap_or_else(crate::github::Ctx::from_env),
505            &root,
506            &n.to_string(),
507        ) {
508            Ok(i) => {
509                let text = format!("{}\n{}", i.title, i.body);
510                issue_ref = Some(speccoder_core::bug::IssueRef {
511                    number: n,
512                    repo,
513                    context_digest: Some(sha256_hex(text.as_bytes())),
514                });
515                extra.issue = Some(state::IssueText {
516                    title: i.title,
517                    body: i.body,
518                });
519            }
520            // An issue never read is not the bug's origin, and its number
521            // must never close it: nothing is recorded.
522            Err(e) => {
523                return CommandResult::new(
524                    "bug",
525                    CommandStatus::Error,
526                    &format!(
527                        "issue #{n} could not be read ({e}); nothing is recorded — check the number, or record the bug without --issue"
528                    ),
529                )
530                .with_code("GITHUB-FAILED");
531            }
532        }
533    }
534
535    // Record: the format's own step, or the standing unit.
536    let (bug, committed) = if home.kind.native() {
537        let Some(provider) = provider.clone() else {
538            return CommandResult::new(
539                "bug",
540                CommandStatus::ConfigError,
541                "recording in the format's own bug structure runs its skill: a model is needed",
542            );
543        };
544        let Some(step) = format.bug_step() else {
545            return refuse(&BugRefusal::new(
546                "BUG-NO-UNIT",
547                "the format has no bug step",
548            ));
549        };
550        let slug = format.bug_slug(&root, &home, &triad.title());
551        let Some((dir, unit)) = format.bug_dir(&home, &slug) else {
552            return refuse(&BugRefusal::new("BUG-NO-UNIT", "no bug directory"));
553        };
554        let ran = crate::hosting::run_scoped(
555            &root,
556            step.skill,
557            &(step.arguments)(&triad, &slug),
558            state,
559            provider,
560            secret,
561            options,
562            step.rules,
563        );
564        let changes: Vec<String> = match ran.data() {
565            Some(CommandData::Skill(s)) => s.changes.iter().map(|(p, _)| p.clone()).collect(),
566            _ => Vec::new(),
567        };
568        if ran.status() != CommandStatus::Ok {
569            return CommandResult::carried("bug", &ran);
570        }
571        let (_, prefix) = repo_prefix(&root);
572        let own = format!("{prefix}{dir}/");
573        let outside: Vec<&String> = changes.iter().filter(|p| !p.starts_with(&own)).collect();
574        if !outside.is_empty() {
575            return CommandResult::new(
576                "bug",
577                CommandStatus::NeedsHuman,
578                &format!(
579                    "the format's bug step wrote outside its bug's directory ({}): look before anything is committed",
580                    outside.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
581                ),
582            )
583            .with_code("BUG-ASSESSMENT-DRIFT");
584        }
585        let read = match format.read_bug(&root, &home, &slug) {
586            Ok(r) => r,
587            Err(r) => {
588                return refuse(&BugRefusal::new(
589                    r.code,
590                    format!("{} (the format's bug step: {})", r.message, ran.message()),
591                ));
592            }
593        };
594        let missing = drift(&triad, &read);
595        if !missing.is_empty() {
596            return refuse(&BugRefusal::new(
597                "BUG-ASSESSMENT-DRIFT",
598                format!(
599                    "{} does not carry exactly your words: {}; speccoder bug --slug {slug} once it does",
600                    read.file,
601                    missing.join("; ")
602                ),
603            ));
604        }
605        let title = triad.title();
606        let committed = crate::hosting::commit_exactly(&root, &changes, &format!("bug: {title}"));
607        if committed.status() != CommandStatus::Ok {
608            return CommandResult::carried("bug", &committed);
609        }
610        let task = format!("bug:{slug}");
611        let home = BugHome {
612            location: dir,
613            ..home
614        };
615        (
616            new_report(
617                home,
618                unit,
619                task.clone(),
620                task,
621                title,
622                triad,
623                config.reproduce_tries,
624                issue_ref,
625            ),
626            committed.message().to_owned(),
627        )
628    } else {
629        // Uncommitted changes in the unit's files would be committed as the bug's.
630        let unit_name = home
631            .location
632            .rsplit('/')
633            .next()
634            .unwrap_or_default()
635            .to_owned();
636        if root.join(&home.location).is_dir() {
637            let dirty = crate::hosting::uncommitted(&root, Some(&unit_name));
638            if !dirty.is_empty() {
639                return CommandResult::new(
640                    "bug",
641                    CommandStatus::GitPrecondition,
642                    &format!(
643                        "{} has uncommitted changes ({}): commit or discard them first; a bug commits exactly its own lines",
644                        home.location,
645                        dirty.join(", ")
646                    ),
647                )
648                .with_code("UNCOMMITTED-INTENT");
649            }
650        }
651        let rec = match format.record_bug(&root, &home, &triad) {
652            Ok(r) => r,
653            Err(r) => return refuse(&r),
654        };
655        // Read back as written: exactly the person's words, or nothing is
656        // committed (constitution I/VII).
657        match format.read_bug(&root, &home, &rec.requirement) {
658            Ok(read) if drift(&triad, &read).is_empty() => {}
659            Ok(read) => {
660                return refuse(&BugRefusal::new(
661                    "BUG-ASSESSMENT-DRIFT",
662                    format!(
663                        "{} does not read back exactly your words ({}); nothing is committed — look at the uncommitted lines",
664                        read.file,
665                        drift(&triad, &read).join("; ")
666                    ),
667                ));
668            }
669            Err(r) => {
670                return refuse(&BugRefusal::new(
671                    "BUG-ASSESSMENT-DRIFT",
672                    format!(
673                        "{} ({}); nothing is committed — look at the uncommitted lines",
674                        r.message, r.code
675                    ),
676                ));
677            }
678        }
679        // The person's own words: approved as they wrote them (where the
680        // format has requirement identifiers to review).
681        if format.requirements_file().is_some() {
682            let approved = crate::review::decide(
683                &root,
684                Some(&rec.unit),
685                std::slice::from_ref(&rec.requirement),
686                "approve",
687                None,
688                via,
689            );
690            if approved.status() != CommandStatus::Ok {
691                return CommandResult::carried("bug", &approved);
692            }
693        }
694        // A unit the person never committed before: its new files too.
695        let files = {
696            let mut f = crate::hosting::uncommitted(&root, Some(&rec.unit));
697            if f.is_empty() {
698                let (_, prefix) = repo_prefix(&root);
699                f = rec.files.iter().map(|p| format!("{prefix}{p}")).collect();
700            }
701            f
702        };
703        let committed =
704            crate::hosting::commit_exactly(&root, &files, &format!("bug: {}", rec.title));
705        if committed.status() != CommandStatus::Ok {
706            return CommandResult::carried("bug", &committed);
707        }
708        (
709            new_report(
710                home,
711                rec.unit,
712                rec.requirement,
713                rec.task,
714                rec.title,
715                triad,
716                config.reproduce_tries,
717                issue_ref,
718            ),
719            committed.message().to_owned(),
720        )
721    };
722    let mut bug = bug;
723    bug.candidate = candidate;
724    bug.recorded_commit = Some(committed);
725    bug.reproductions = person.iter().map(|(r, _)| r.clone()).collect();
726    let files: Vec<(String, String)> = person
727        .iter()
728        .map(|(r, c)| (r.file.clone(), c.clone()))
729        .collect();
730    finish_record(
731        &root, &plane, bug, files, extra, via, state, provider, secret, req.run, options,
732    )
733}
734
735/// The refusal of a slug that is not one.
736fn invalid_slug(slug: &str) -> CommandResult {
737    CommandResult::new(
738        "bug",
739        CommandStatus::UsageError,
740        &format!(
741            "`{slug}` is not a bug slug: lowercase letters, digits and `-`, at most {} long",
742            speccoder_core::bug::MAX_SLUG
743        ),
744    )
745    .with_code("USAGE")
746}
747
748fn repo_prefix(root: &Path) -> (PathBuf, String) {
749    let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
750    let top = crate::git::Git::at(root)
751        .toplevel()
752        .ok()
753        .flatten()
754        .map_or_else(|| root.to_path_buf(), |t| canonical(&t));
755    let prefix = canonical(root)
756        .strip_prefix(&top)
757        .map(|p| p.to_string_lossy().replace('\\', "/"))
758        .unwrap_or_default();
759    let prefix = if prefix.is_empty() {
760        prefix
761    } else {
762        format!("{prefix}/")
763    };
764    (top, prefix)
765}
766
767#[allow(clippy::too_many_arguments)]
768fn new_report(
769    home: BugHome,
770    unit: String,
771    requirement: String,
772    task: String,
773    title: String,
774    triad: Triad,
775    max_tries: u32,
776    issue: Option<speccoder_core::bug::IssueRef>,
777) -> BugReport {
778    BugReport {
779        home,
780        unit,
781        requirement,
782        task,
783        title,
784        triad,
785        stage: Stage::Record,
786        outcome: Outcome::Recorded,
787        verdict: None,
788        code: None,
789        reproductions: Vec::new(),
790        held_out: Vec::new(),
791        held_out_sealed: None,
792        held_out_reason: None,
793        confirm_digest: None,
794        confirmation: None,
795        rejections: Vec::new(),
796        acceptance: None,
797        pass_to_pass: None,
798        revert: None,
799        special_case: Vec::new(),
800        tries: Vec::new(),
801        max_tries,
802        handoff: None,
803        issue,
804        candidate: None,
805        recorded_commit: None,
806        commit: None,
807        run_id: None,
808    }
809}
810
811/// The origin, the state, the event; then, when asked, the run.
812#[allow(clippy::too_many_arguments)]
813fn finish_record(
814    root: &Path,
815    plane: &Path,
816    mut bug: BugReport,
817    person_files: Vec<(String, String)>,
818    extra: state::Extra,
819    via: Via,
820    state: &Path,
821    provider: Option<Arc<dyn ModelProvider>>,
822    secret: Option<&str>,
823    run: bool,
824    options: &RunOptions,
825) -> CommandResult {
826    let by = extra.by.clone().unwrap_or_default();
827    let mut origins = vec![Origin::Bug {
828        unit: bug.unit.clone(),
829        requirement: bug.requirement.clone(),
830        task: bug.task.clone(),
831        triad_sha256: bug.triad.digest.clone(),
832        by,
833        via,
834        at: confirm::now(),
835    }];
836    if let Some(i) = &bug.issue {
837        origins.push(Origin::Issue {
838            number: i.number,
839            repo: i.repo.clone(),
840            context_sha256: i.context_digest.clone(),
841        });
842    }
843    if let Some(conversation) = &extra.asked {
844        origins.push(speccoder_core::ladder::asked(conversation, confirm::now()));
845    }
846    // Exactly one origin: one already there for the same words
847    // (a bug read in again) stands; any other failure is said.
848    let subject = speccoder_core::ladder::subject(&bug.unit, &bug.requirement);
849    if let Err(e) = crate::ladder::record_origin(plane, &subject, origins) {
850        let same = crate::ladder::of(plane, &subject).iter().any(
851            |o| matches!(o, Origin::Bug { triad_sha256, .. } if *triad_sha256 == bug.triad.digest),
852        );
853        if !same {
854            return CommandResult::new(
855                "bug",
856                CommandStatus::Error,
857                &format!(
858                    "the bug's origin could not be written ({e}); its words are committed ({}), nothing else is kept",
859                    bug.recorded_commit.as_deref().unwrap_or("not yet")
860                ),
861            );
862        }
863    }
864    bug.stage = Stage::Reproduce;
865    let saved = state::save(plane, &bug)
866        .and_then(|()| state::save_extra(plane, &bug.unit, &bug.task, &extra))
867        .and_then(|()| state::save_repro(plane, &bug.unit, &bug.task, &person_files));
868    if let Err(e) = saved {
869        return CommandResult::new(
870            "bug",
871            CommandStatus::Error,
872            &format!("the bug could not be kept: {e}"),
873        );
874    }
875    emit(
876        options,
877        &bug.task,
878        BugEvent::Recorded {
879            home: bug.home.kind.as_str().to_owned(),
880            unit: bug.unit.clone(),
881            requirement: bug.requirement.clone(),
882            triad_digest: bug.triad.digest.clone(),
883            via: via.as_str().to_owned(),
884        },
885    );
886    let said = format!(
887        "recorded \"{}\" as {} and {} in {} ({})",
888        bug.title, bug.requirement, bug.task, bug.unit, bug.home.reason
889    );
890    if !run {
891        return result(
892            CommandStatus::Ok,
893            &format!(
894                "{said}; reproduce it: speccoder bug run --task {}",
895                bug.key()
896            ),
897            &bug,
898        );
899    }
900    let r = run_bug(root, &bug, state, provider, secret, options);
901    let status = r.status();
902    let message = format!("{said}; {}", r.message());
903    r.reworded(status, &message)
904}
905
906/// Runs the bug's task (reproduce, or its confirmed fix) and answers with
907/// the bug.
908#[must_use]
909pub fn run_bug(
910    root: &Path,
911    bug: &BugReport,
912    state: &Path,
913    provider: Option<Arc<dyn ModelProvider>>,
914    secret: Option<&str>,
915    options: &RunOptions,
916) -> CommandResult {
917    let Some(provider) = provider else {
918        return CommandResult::new(
919            "bug",
920            CommandStatus::ConfigError,
921            "running a bug needs a model",
922        );
923    };
924    let ran = if bug.home.kind.native() {
925        crate::pipeline::run_native_bug(root, bug, state, provider, secret, options)
926    } else {
927        crate::run_with_options(
928            root,
929            Some(&bug.unit),
930            &bug.task,
931            state,
932            provider,
933            secret,
934            options,
935        )
936    };
937    let plane = crate::worktree::project_state_dir(state, root);
938    let now = state::load(&plane, &bug.unit, &bug.task).unwrap_or_else(|| bug.clone());
939    let mut r = CommandResult::new("bug", ran.status(), ran.message())
940        .with_next(if ran.next().is_empty() {
941            handoff::next(&now, now.outcome)
942        } else {
943            ran.next().to_vec()
944        })
945        .with_data(CommandData::Bug(Box::new(now.clone())));
946    let explicit: Option<String> = if ran.has_explicit_code() {
947        ran.code().map(|e| e.name.to_owned())
948    } else {
949        None
950    };
951    let code: Option<String> = explicit.or_else(|| now.code.clone());
952    if let Some(code) = code {
953        r = r.with_code(&code);
954    }
955    r
956}
957
958/// `speccoder bug run --task <unit>/<task>` or `--slug S`, with the
959/// configured model.
960#[must_use]
961pub fn run(
962    path: &Path,
963    slug: Option<&str>,
964    task: Option<&str>,
965    state: Option<&Path>,
966    options: &RunOptions,
967) -> CommandResult {
968    if slug.is_none()
969        && let Err(r) = precheck(path, task, state)
970    {
971        return *r;
972    }
973    with_model(path, state, |state, provider, key| {
974        run_with(path, slug, task, state, provider, Some(key), options)
975    })
976}
977
978/// [`run()`] against `state` with `provider`.
979#[must_use]
980pub fn run_with(
981    path: &Path,
982    slug: Option<&str>,
983    task: Option<&str>,
984    state: &Path,
985    provider: Arc<dyn ModelProvider>,
986    secret: Option<&str>,
987    options: &RunOptions,
988) -> CommandResult {
989    let (root, format) = match root_of(path) {
990        Ok(x) => x,
991        Err(r) => return *r,
992    };
993    if let Some(s) = slug.filter(|s| !speccoder_core::bug::valid_slug(s)) {
994        return invalid_slug(s);
995    }
996    let plane = crate::worktree::project_state_dir(state, &root);
997    let found = match (slug, task) {
998        (Some(s), _) => format
999            .bug_unit(&root)
1000            .ok()
1001            .and_then(|h| format.bug_dir(&h, s))
1002            .and_then(|(_, unit)| state::load(&plane, &unit, &format!("bug:{s}"))),
1003        (None, Some(t)) => state::find(&plane, t),
1004        (None, None) => None,
1005    };
1006    match (found, slug) {
1007        (Some(bug), _) => run_bug(&root, &bug, state, Some(provider), secret, options),
1008        // A bug the person assessed with the format's own step: read it in.
1009        (None, Some(s)) => bug_with(
1010            path,
1011            &Request {
1012                slug: Some(s.to_owned()),
1013                run: true,
1014                ..Request::default()
1015            },
1016            Via::Terminal,
1017            state,
1018            Some(provider),
1019            secret,
1020            options,
1021        ),
1022        (None, None) => CommandResult::new(
1023            "bug",
1024            CommandStatus::NotFound,
1025            &format!(
1026                "no bug {}",
1027                task.unwrap_or("named: pass --task <unit>/<task> or --slug <slug>")
1028            ),
1029        ),
1030    }
1031}
1032
1033/// `speccoder bug confirm <unit>/<task> --digest D` (or `--reject`):
1034/// the person's one decision on the reproductions shown; with `run`, the
1035/// fix (or the next try) runs at once.
1036#[must_use]
1037#[allow(clippy::too_many_arguments)]
1038pub fn confirm(
1039    path: &Path,
1040    key: &str,
1041    digest: &str,
1042    reject: Option<&str>,
1043    run: bool,
1044    via: Via,
1045    state: Option<&Path>,
1046    options: &RunOptions,
1047) -> CommandResult {
1048    // What can be refused is refused before a model is opened.
1049    if let Some(r) = precheck(path, Some(key), state).err().or_else(|| {
1050        (reject.is_some_and(|r| r.trim().is_empty())).then(|| {
1051            Box::new(
1052                CommandResult::new(
1053                    "bug",
1054                    CommandStatus::UsageError,
1055                    "a reason is needed: the author's next try is told it",
1056                )
1057                .with_code("USAGE"),
1058            )
1059        })
1060    }) {
1061        return *r;
1062    }
1063    if run {
1064        return with_model(path, state, |state, provider, k| {
1065            confirm_with(
1066                path,
1067                key,
1068                digest,
1069                reject,
1070                via,
1071                state,
1072                Some(provider),
1073                Some(k),
1074                options,
1075            )
1076        });
1077    }
1078    match state_or_env(state) {
1079        Ok(state) => confirm_with(path, key, digest, reject, via, &state, None, None, options),
1080        Err(r) => *r,
1081    }
1082}
1083
1084/// [`confirm()`] against `state`; `provider` runs what follows (none: only
1085/// the decision is recorded).
1086#[allow(clippy::too_many_arguments)]
1087#[must_use]
1088pub fn confirm_with(
1089    path: &Path,
1090    key: &str,
1091    digest: &str,
1092    reject: Option<&str>,
1093    via: Via,
1094    state: &Path,
1095    provider: Option<Arc<dyn ModelProvider>>,
1096    secret: Option<&str>,
1097    options: &RunOptions,
1098) -> CommandResult {
1099    let (root, format) = match root_of(path) {
1100        Ok(x) => x,
1101        Err(r) => return *r,
1102    };
1103    let plane = crate::worktree::project_state_dir(state, &root);
1104    let Some(mut bug) = state::find(&plane, key) else {
1105        return CommandResult::new("bug", CommandStatus::NotFound, &format!("no bug {key}"));
1106    };
1107    if bug.outcome != Outcome::AwaitingConfirmation {
1108        return result(
1109            CommandStatus::UsageError,
1110            &format!(
1111                "{key} is not waiting for a confirmation ({})",
1112                bug.outcome.as_str()
1113            ),
1114            &bug,
1115        );
1116    }
1117    let shown = bug.confirm_digest.clone().unwrap_or_default();
1118    if digest.trim() != shown {
1119        return CommandResult::new(
1120            "bug",
1121            CommandStatus::UsageError,
1122            &format!("the digest does not match the reproductions shown ({shown}): look at them again (`speccoder bug confirm {key}` in a terminal shows them)"),
1123        )
1124        .with_code("USAGE");
1125    }
1126    // The triad as it reads now: a reworded one lapses what was shown.
1127    match crate::pipeline::bug_triad_now(format, &root, &bug) {
1128        Ok(now) if now.digest != bug.triad.digest => {
1129            let _ = confirm::lapse(&plane, &mut bug, now);
1130            return result(
1131                CommandStatus::NeedsHuman,
1132                "the triad was reworded since the reproductions were written: they lapse; reproduce again",
1133                &bug,
1134            );
1135        }
1136        Err(r) => return refuse(&r),
1137        Ok(_) => {}
1138    }
1139    let by = crate::approvals::who_in(&root);
1140    if let Some(reason) = reject {
1141        let reason = reason.trim();
1142        if reason.is_empty() {
1143            return CommandResult::new(
1144                "bug",
1145                CommandStatus::UsageError,
1146                "a reason is needed: the author's next try is told it",
1147            )
1148            .with_code("USAGE");
1149        }
1150        if let Err(e) = crate::pipeline::bug_reject(&plane, &mut bug, reason, via) {
1151            return CommandResult::new(
1152                "bug",
1153                CommandStatus::Error,
1154                &format!("the rejection could not be kept: {e}; nothing was rejected"),
1155            );
1156        }
1157        emit(
1158            options,
1159            &bug.task,
1160            BugEvent::ConfirmRejected {
1161                digest: shown,
1162                reason: reason.to_owned(),
1163            },
1164        );
1165        return match provider {
1166            Some(p) => run_bug(&root, &bug, state, Some(p), secret, options),
1167            None => result(
1168                CommandStatus::Ok,
1169                &format!(
1170                    "rejected; the author is told why on the next try: speccoder bug run --task {key}"
1171                ),
1172                &bug,
1173            ),
1174        };
1175    }
1176    match confirm::freeze(
1177        &plane,
1178        &mut bug,
1179        &by,
1180        via,
1181        speccoder_core::bug::Basis::Shown,
1182    ) {
1183        Ok(_) => {
1184            emit(
1185                options,
1186                &bug.task,
1187                BugEvent::Confirmed {
1188                    digest: shown,
1189                    by: by.clone(),
1190                    via: via.as_str().to_owned(),
1191                    basis: "shown".to_owned(),
1192                },
1193            );
1194            match provider {
1195                Some(p) => run_bug(&root, &bug, state, Some(p), secret, options),
1196                None => result(
1197                    CommandStatus::Ok,
1198                    &format!("confirmed and frozen; fix it: speccoder bug run --task {key}"),
1199                    &bug,
1200                ),
1201            }
1202        }
1203        Err(e) => CommandResult::new(
1204            "bug",
1205            CommandStatus::Error,
1206            &format!("the confirmation could not be recorded: {e}"),
1207        ),
1208    }
1209}
1210
1211/// The bug named `key` exists (no model needed to say it does not).
1212fn precheck(
1213    path: &Path,
1214    key: Option<&str>,
1215    state: Option<&Path>,
1216) -> Result<(), Box<CommandResult>> {
1217    let (root, _) = root_of(path)?;
1218    let Some(key) = key else {
1219        return Err(Box::new(
1220            CommandResult::new(
1221                "bug",
1222                CommandStatus::UsageError,
1223                "name the bug: --task <unit>/<task> or --slug <slug>",
1224            )
1225            .with_code("USAGE"),
1226        ));
1227    };
1228    let state = state_or_env(state)?;
1229    let plane = crate::worktree::project_state_dir(&state, &root);
1230    if state::find(&plane, key).is_none() {
1231        return Err(Box::new(CommandResult::new(
1232            "bug",
1233            CommandStatus::NotFound,
1234            &format!("no bug {key}"),
1235        )));
1236    }
1237    Ok(())
1238}
1239
1240/// The bug named `key`, as speccoder keeps it.
1241#[must_use]
1242pub fn show(path: &Path, key: &str) -> CommandResult {
1243    let Ok((root, _)) = root_of(path) else {
1244        return CommandResult::new("bug", CommandStatus::NotAProject, "not a project");
1245    };
1246    let Some(state) = crate::worktree::state_dir() else {
1247        return CommandResult::new("bug", CommandStatus::ConfigError, "no state directory")
1248            .with_code("NO-STATE-DIR");
1249    };
1250    let plane = crate::worktree::project_state_dir(&state, &root);
1251    match state::find(&plane, key) {
1252        Some(b) => result(
1253            CommandStatus::Ok,
1254            &format!("{key}: {}", b.outcome.as_str()),
1255            &b,
1256        ),
1257        None => CommandResult::new("bug", CommandStatus::NotFound, &format!("no bug {key}")),
1258    }
1259}
1260
1261/// What a person is shown before confirming `key` in a terminal.
1262#[must_use]
1263pub fn shown(state: &Path, root: &Path, key: &str) -> Option<crate::approvals::ConfirmRequest> {
1264    let plane = crate::worktree::project_state_dir(state, root);
1265    let bug = state::find(&plane, key)?;
1266    let digest = bug.confirm_digest.clone()?;
1267    let paths: Vec<String> = bug.reproductions.iter().map(|r| r.file.clone()).collect();
1268    let files = state::repro(&plane, &bug.unit, &bug.task, &paths);
1269    Some(crate::approvals::ConfirmRequest {
1270        bug: bug.key(),
1271        title: bug.title.clone(),
1272        digest,
1273        reproductions: bug
1274            .reproductions
1275            .iter()
1276            .map(|r| crate::approvals::ShownReproduction {
1277                statement: r.statement.clone(),
1278                words: bug
1279                    .triad
1280                    .statement(&r.statement)
1281                    .map(|s| s.text.clone())
1282                    .unwrap_or_default(),
1283                file: r.file.clone(),
1284                test: r.test.clone(),
1285                argv: r.argv.clone(),
1286                excerpt: String::new(),
1287                content: files
1288                    .iter()
1289                    .find(|(p, _)| *p == r.file)
1290                    .map(|(_, c)| c.clone())
1291                    .unwrap_or_default(),
1292            })
1293            .collect(),
1294        warning: held_out_warning(&bug),
1295    })
1296}
1297
1298/// What a person confirming `bug` must know first: no held-out variant
1299/// could be kept, so the fix cannot be verified.
1300#[must_use]
1301pub fn held_out_warning(bug: &BugReport) -> Option<String> {
1302    bug.held_out_reason.as_ref().map(|why| {
1303        format!("no held-out variant could be kept ({why}): the fix cannot be verified until one is — confirming now only leads to a failed verdict")
1304    })
1305}