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