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() && format.bug_step().is_some())
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.
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(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                asked: req.asked.clone(),
370                ..state::Extra::default()
371            },
372            via,
373            state,
374            provider,
375            secret,
376            req.run,
377            options,
378        );
379    }
380
381    // A format that writes its bugs itself (Kiro): nothing to record here.
382    if home.kind.native() && format.bug_step().is_none() {
383        return refuse(&writes_its_own(&home));
384    }
385
386    // The person's words, checked before anything is written.
387    let triad = match Triad::new(&req.symptom, &req.expected, &req.unchanged, sha256_hex) {
388        Ok(t) => t,
389        Err(e) => return refuse(&BugRefusal::new(e.code(), e.message())),
390    };
391    let mut person: Vec<(Reproduction, String)> = Vec::new();
392    if !req.tests.is_empty() {
393        if req.tests.len() != triad.expected.len() {
394            return refuse(&BugRefusal::new(
395                "BUG-TEST-COVERAGE",
396                format!(
397                    "{} --test for {} --expected statement(s): one test per stated behaviour, in order",
398                    req.tests.len(),
399                    triad.expected.len()
400                ),
401            ));
402        }
403        let runner = match crate::sandbox::runner_at(&root) {
404            Ok((r, _)) => r,
405            Err(e) => return CommandResult::new("bug", CommandStatus::ConfigError, &e),
406        };
407        for (spec, statement) in req.tests.iter().zip(&triad.expected) {
408            let n = match named::resolve(&root, spec, config.bug_test.as_deref()) {
409                Ok(n) => n,
410                Err(r) => return refuse(&r),
411            };
412            let (class, _) = named::baseline(&root, &n.argv, &runner);
413            if class != speccoder_core::verify::RedClass::Assertion {
414                return refuse(&named::not_red(spec, class));
415            }
416            person.push((
417                Reproduction {
418                    statement: statement.id.clone(),
419                    file: n.file,
420                    test: n.name,
421                    source: Source::Person,
422                    baseline: vec![class, class],
423                    digest: format!("sha256:{}", sha256_hex(n.content.as_bytes())),
424                    argv: n.argv,
425                    final_: Final::NotRun,
426                    revert: Revert::NotRun,
427                },
428                n.content,
429            ));
430        }
431    }
432    let mut extra = state::Extra {
433        by: Some(by.clone()),
434        via: Some(via),
435        asked: req.asked.clone(),
436        ..state::Extra::default()
437    };
438    let mut candidate = None;
439    if let Some(spec) = req.candidate.as_deref() {
440        let Some(c) = candidate::parse(spec) else {
441            return CommandResult::new(
442                "bug",
443                CommandStatus::UsageError,
444                "--candidate takes a patch file or pr:N",
445            )
446            .with_code("USAGE");
447        };
448        let (diff, head) = match &c {
449            candidate::Spec::Patch(p) => match candidate::read_patch(&root, path, p, via) {
450                Ok(d) => (d, None),
451                Err(e) => {
452                    return CommandResult::new("bug", CommandStatus::UsageError, &e)
453                        .with_code("USAGE");
454                }
455            },
456            candidate::Spec::Pr(n) => match crate::github::pull_diff(
457                &req.github
458                    .clone()
459                    .unwrap_or_else(crate::github::Ctx::from_env),
460                &root,
461                *n,
462            ) {
463                Ok((d, head)) => (d, Some(head)),
464                Err(e) => {
465                    return CommandResult::new(
466                        "bug",
467                        CommandStatus::Error,
468                        &format!("pull request #{n}: {e}"),
469                    )
470                    .with_code("GITHUB-FAILED");
471                }
472            },
473        };
474        let policy = crate::tools::policy::Policy::new(&[], &[]).rules(format.rules());
475        let touched = match candidate::touched(&repo_prefix(&root).0, &diff) {
476            Ok(t) => t,
477            Err(r) => {
478                return refuse(&BugRefusal::new(
479                    "BUG-CANDIDATE-APPLY",
480                    format!(
481                        "the candidate cannot be read as a patch: {}",
482                        r.paths.join("; ")
483                    ),
484                ));
485            }
486        };
487        let judged = candidate::judge_paths(&touched, &policy, &[], &[]);
488        if !judged.is_empty() {
489            return refuse(&BugRefusal::new(
490                "BUG-CANDIDATE-JUDGE",
491                format!(
492                    "the candidate touches what judges it: {}",
493                    judged.join(", ")
494                ),
495            ));
496        }
497        candidate = Some(candidate::record(&c, &diff, head));
498        extra.candidate_patch = Some(diff);
499    }
500    let mut issue_ref = None;
501    if let Some(n) = req.issue {
502        let repo = crate::github::slug_of(&root);
503        match crate::github::issue(
504            &req.github
505                .clone()
506                .unwrap_or_else(crate::github::Ctx::from_env),
507            &root,
508            &n.to_string(),
509        ) {
510            Ok(i) => {
511                let text = format!("{}\n{}", i.title, i.body);
512                issue_ref = Some(speccoder_core::bug::IssueRef {
513                    number: n,
514                    repo,
515                    context_digest: Some(sha256_hex(text.as_bytes())),
516                });
517                extra.issue = Some(state::IssueText {
518                    title: i.title,
519                    body: i.body,
520                });
521            }
522            // An issue never read is not the bug's origin, and its number
523            // must never close it: nothing is recorded.
524            Err(e) => {
525                return CommandResult::new(
526                    "bug",
527                    CommandStatus::Error,
528                    &format!(
529                        "issue #{n} could not be read ({e}); nothing is recorded — check the number, or record the bug without --issue"
530                    ),
531                )
532                .with_code("GITHUB-FAILED");
533            }
534        }
535    }
536
537    // Record: the format's own step, or the standing unit.
538    let (bug, committed) = if home.kind.native() {
539        let Some(step) = format.bug_step() else {
540            return refuse(&writes_its_own(&home));
541        };
542        let Some(provider) = provider.clone() else {
543            return CommandResult::new(
544                "bug",
545                CommandStatus::ConfigError,
546                "recording in the format's own bug structure runs its skill: a model is needed",
547            );
548        };
549        let slug = format.bug_slug(&root, &home, &triad.title());
550        let Some((dir, unit)) = format.bug_dir(&home, &slug) else {
551            return refuse(&BugRefusal::new("BUG-NO-UNIT", "no bug directory"));
552        };
553        let ran = crate::hosting::run_scoped(
554            &root,
555            step.skill,
556            &(step.arguments)(&triad, &slug),
557            state,
558            provider,
559            secret,
560            options,
561            step.rules,
562        );
563        let changes: Vec<String> = match ran.data() {
564            Some(CommandData::Skill(s)) => s.changes.iter().map(|(p, _)| p.clone()).collect(),
565            _ => Vec::new(),
566        };
567        if ran.status() != CommandStatus::Ok {
568            return CommandResult::carried("bug", &ran);
569        }
570        let (_, prefix) = repo_prefix(&root);
571        let own = format!("{prefix}{dir}/");
572        let outside: Vec<&String> = changes.iter().filter(|p| !p.starts_with(&own)).collect();
573        if !outside.is_empty() {
574            return CommandResult::new(
575                "bug",
576                CommandStatus::NeedsHuman,
577                &format!(
578                    "the format's bug step wrote outside its bug's directory ({}): look before anything is committed",
579                    outside.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
580                ),
581            )
582            .with_code("BUG-ASSESSMENT-DRIFT");
583        }
584        let read = match format.read_bug(&root, &home, &slug) {
585            Ok(r) => r,
586            Err(r) => {
587                return refuse(&BugRefusal::new(
588                    r.code,
589                    format!("{} (the format's bug step: {})", r.message, ran.message()),
590                ));
591            }
592        };
593        let missing = drift(&triad, &read);
594        if !missing.is_empty() {
595            return refuse(&BugRefusal::new(
596                "BUG-ASSESSMENT-DRIFT",
597                format!(
598                    "{} does not carry exactly your words: {}; speccoder bug --slug {slug} once it does",
599                    read.file,
600                    missing.join("; ")
601                ),
602            ));
603        }
604        let title = triad.title();
605        let committed = crate::hosting::commit_exactly(&root, &changes, &format!("bug: {title}"));
606        if committed.status() != CommandStatus::Ok {
607            return CommandResult::carried("bug", &committed);
608        }
609        let task = format!("bug:{slug}");
610        let home = BugHome {
611            location: dir,
612            ..home
613        };
614        (
615            new_report(
616                home,
617                unit,
618                task.clone(),
619                task,
620                title,
621                triad,
622                config.reproduce_tries,
623                issue_ref,
624            ),
625            committed.message().to_owned(),
626        )
627    } else {
628        // Uncommitted changes in the unit's files would be committed as the bug's.
629        let unit_name = home
630            .location
631            .rsplit('/')
632            .next()
633            .unwrap_or_default()
634            .to_owned();
635        if root.join(&home.location).is_dir() {
636            let dirty = crate::hosting::uncommitted(&root, Some(&unit_name));
637            if !dirty.is_empty() {
638                return CommandResult::new(
639                    "bug",
640                    CommandStatus::GitPrecondition,
641                    &format!(
642                        "{} has uncommitted changes ({}): commit or discard them first; a bug commits exactly its own lines",
643                        home.location,
644                        dirty.join(", ")
645                    ),
646                )
647                .with_code("UNCOMMITTED-INTENT");
648            }
649        }
650        let rec = match format.record_bug(&root, &home, &triad) {
651            Ok(r) => r,
652            Err(r) => return refuse(&r),
653        };
654        // Read back as written: exactly the person's words, or nothing is
655        // committed (constitution I/VII).
656        match format.read_bug(&root, &home, &rec.requirement) {
657            Ok(read) if drift(&triad, &read).is_empty() => {}
658            Ok(read) => {
659                return refuse(&BugRefusal::new(
660                    "BUG-ASSESSMENT-DRIFT",
661                    format!(
662                        "{} does not read back exactly your words ({}); nothing is committed — look at the uncommitted lines",
663                        read.file,
664                        drift(&triad, &read).join("; ")
665                    ),
666                ));
667            }
668            Err(r) => {
669                return refuse(&BugRefusal::new(
670                    "BUG-ASSESSMENT-DRIFT",
671                    format!(
672                        "{} ({}); nothing is committed — look at the uncommitted lines",
673                        r.message, r.code
674                    ),
675                ));
676            }
677        }
678        // The person's own words: approved as they wrote them (where the
679        // format has requirement identifiers to review).
680        if format.requirements_file().is_some() {
681            let approved = crate::review::decide(
682                &root,
683                Some(&rec.unit),
684                std::slice::from_ref(&rec.requirement),
685                "approve",
686                None,
687                via,
688            );
689            if approved.status() != CommandStatus::Ok {
690                return CommandResult::carried("bug", &approved);
691            }
692        }
693        // A unit the person never committed before: its new files too.
694        let files = {
695            let mut f = crate::hosting::uncommitted(&root, Some(&rec.unit));
696            if f.is_empty() {
697                let (_, prefix) = repo_prefix(&root);
698                f = rec.files.iter().map(|p| format!("{prefix}{p}")).collect();
699            }
700            f
701        };
702        let committed =
703            crate::hosting::commit_exactly(&root, &files, &format!("bug: {}", rec.title));
704        if committed.status() != CommandStatus::Ok {
705            return CommandResult::carried("bug", &committed);
706        }
707        (
708            new_report(
709                home,
710                rec.unit,
711                rec.requirement,
712                rec.task,
713                rec.title,
714                triad,
715                config.reproduce_tries,
716                issue_ref,
717            ),
718            committed.message().to_owned(),
719        )
720    };
721    let mut bug = bug;
722    bug.candidate = candidate;
723    bug.recorded_commit = Some(committed);
724    bug.reproductions = person.iter().map(|(r, _)| r.clone()).collect();
725    let files: Vec<(String, String)> = person
726        .iter()
727        .map(|(r, c)| (r.file.clone(), c.clone()))
728        .collect();
729    finish_record(
730        &root, &plane, bug, files, extra, via, state, provider, secret, req.run, options,
731    )
732}
733
734/// A format that writes its bugs itself: where, and the way on.
735fn writes_its_own(home: &BugHome) -> BugRefusal {
736    BugRefusal::new(
737        "BUG-NO-UNIT",
738        format!(
739            "this project keeps each bug in the format's own files: {} in {}/<name>/",
740            home.reason, home.location
741        ),
742    )
743}
744
745/// The refusal of a slug that is not one.
746fn invalid_slug(slug: &str) -> CommandResult {
747    CommandResult::new(
748        "bug",
749        CommandStatus::UsageError,
750        &format!(
751            "`{slug}` is not a bug slug: lowercase letters, digits and `-`, at most {} long",
752            speccoder_core::bug::MAX_SLUG
753        ),
754    )
755    .with_code("USAGE")
756}
757
758fn repo_prefix(root: &Path) -> (PathBuf, String) {
759    let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
760    let top = crate::git::Git::at(root)
761        .toplevel()
762        .ok()
763        .flatten()
764        .map_or_else(|| root.to_path_buf(), |t| canonical(&t));
765    let prefix = canonical(root)
766        .strip_prefix(&top)
767        .map(|p| p.to_string_lossy().replace('\\', "/"))
768        .unwrap_or_default();
769    let prefix = if prefix.is_empty() {
770        prefix
771    } else {
772        format!("{prefix}/")
773    };
774    (top, prefix)
775}
776
777#[allow(clippy::too_many_arguments)]
778fn new_report(
779    home: BugHome,
780    unit: String,
781    requirement: String,
782    task: String,
783    title: String,
784    triad: Triad,
785    max_tries: u32,
786    issue: Option<speccoder_core::bug::IssueRef>,
787) -> BugReport {
788    BugReport {
789        home,
790        unit,
791        requirement,
792        task,
793        title,
794        triad,
795        stage: Stage::Record,
796        outcome: Outcome::Recorded,
797        verdict: None,
798        code: None,
799        reproductions: Vec::new(),
800        held_out: Vec::new(),
801        held_out_sealed: None,
802        held_out_reason: None,
803        confirm_digest: None,
804        confirmation: None,
805        rejections: Vec::new(),
806        acceptance: None,
807        pass_to_pass: None,
808        revert: None,
809        special_case: Vec::new(),
810        tries: Vec::new(),
811        max_tries,
812        handoff: None,
813        issue,
814        candidate: None,
815        recorded_commit: None,
816        commit: None,
817        run_id: None,
818    }
819}
820
821/// The origin, the state, the event; then, when asked, the run.
822#[allow(clippy::too_many_arguments)]
823fn finish_record(
824    root: &Path,
825    plane: &Path,
826    mut bug: BugReport,
827    person_files: Vec<(String, String)>,
828    extra: state::Extra,
829    via: Via,
830    state: &Path,
831    provider: Option<Arc<dyn ModelProvider>>,
832    secret: Option<&str>,
833    run: bool,
834    options: &RunOptions,
835) -> CommandResult {
836    let by = extra.by.clone().unwrap_or_default();
837    let mut origins = vec![Origin::Bug {
838        unit: bug.unit.clone(),
839        requirement: bug.requirement.clone(),
840        task: bug.task.clone(),
841        triad_sha256: bug.triad.digest.clone(),
842        by,
843        via,
844        at: confirm::now(),
845    }];
846    if let Some(i) = &bug.issue {
847        origins.push(Origin::Issue {
848            number: i.number,
849            repo: i.repo.clone(),
850            context_sha256: i.context_digest.clone(),
851        });
852    }
853    if let Some(conversation) = &extra.asked {
854        origins.push(speccoder_core::ladder::asked(conversation, confirm::now()));
855    }
856    // Exactly one origin: one already there for the same words
857    // (a bug read in again) stands; any other failure is said.
858    let subject = speccoder_core::ladder::subject(&bug.unit, &bug.requirement);
859    if let Err(e) = crate::ladder::record_origin(plane, &subject, origins) {
860        let same = crate::ladder::of(plane, &subject).iter().any(
861            |o| matches!(o, Origin::Bug { triad_sha256, .. } if *triad_sha256 == bug.triad.digest),
862        );
863        if !same {
864            return CommandResult::new(
865                "bug",
866                CommandStatus::Error,
867                &format!(
868                    "the bug's origin could not be written ({e}); its words are committed ({}), nothing else is kept",
869                    bug.recorded_commit.as_deref().unwrap_or("not yet")
870                ),
871            );
872        }
873    }
874    bug.stage = Stage::Reproduce;
875    let saved = state::save(plane, &bug)
876        .and_then(|()| state::save_extra(plane, &bug.unit, &bug.task, &extra))
877        .and_then(|()| state::save_repro(plane, &bug.unit, &bug.task, &person_files));
878    if let Err(e) = saved {
879        return CommandResult::new(
880            "bug",
881            CommandStatus::Error,
882            &format!("the bug could not be kept: {e}"),
883        );
884    }
885    emit(
886        options,
887        &bug.task,
888        BugEvent::Recorded {
889            home: bug.home.kind.as_str().to_owned(),
890            unit: bug.unit.clone(),
891            requirement: bug.requirement.clone(),
892            triad_digest: bug.triad.digest.clone(),
893            via: via.as_str().to_owned(),
894        },
895    );
896    let said = format!(
897        "recorded \"{}\" as {} and {} in {} ({})",
898        bug.title, bug.requirement, bug.task, bug.unit, bug.home.reason
899    );
900    if !run {
901        return result(
902            CommandStatus::Ok,
903            &format!(
904                "{said}; reproduce it: speccoder bug run --task {}",
905                bug.key()
906            ),
907            &bug,
908        );
909    }
910    let r = run_bug(root, &bug, state, provider, secret, options);
911    let status = r.status();
912    let message = format!("{said}; {}", r.message());
913    r.reworded(status, &message)
914}
915
916/// Runs the bug's task (reproduce, or its confirmed fix) and answers with
917/// the bug.
918#[must_use]
919pub fn run_bug(
920    root: &Path,
921    bug: &BugReport,
922    state: &Path,
923    provider: Option<Arc<dyn ModelProvider>>,
924    secret: Option<&str>,
925    options: &RunOptions,
926) -> CommandResult {
927    let Some(provider) = provider else {
928        return CommandResult::new(
929            "bug",
930            CommandStatus::ConfigError,
931            "running a bug needs a model",
932        );
933    };
934    let ran = if bug.home.kind.native() {
935        crate::pipeline::run_native_bug(root, bug, state, provider, secret, options)
936    } else {
937        crate::run_with_options(
938            root,
939            Some(&bug.unit),
940            &bug.task,
941            state,
942            provider,
943            secret,
944            options,
945        )
946    };
947    let plane = crate::worktree::project_state_dir(state, root);
948    let now = state::load(&plane, &bug.unit, &bug.task).unwrap_or_else(|| bug.clone());
949    let mut r = CommandResult::new("bug", ran.status(), ran.message())
950        .with_next(if ran.next().is_empty() {
951            handoff::next(&now, now.outcome)
952        } else {
953            ran.next().to_vec()
954        })
955        .with_data(CommandData::Bug(Box::new(now.clone())));
956    let explicit: Option<String> = if ran.has_explicit_code() {
957        ran.code().map(|e| e.name.to_owned())
958    } else {
959        None
960    };
961    let code: Option<String> = explicit.or_else(|| now.code.clone());
962    if let Some(code) = code {
963        r = r.with_code(&code);
964    }
965    r
966}
967
968/// `speccoder bug run --task <unit>/<task>` or `--slug S`, with the
969/// configured model.
970#[must_use]
971pub fn run(
972    path: &Path,
973    slug: Option<&str>,
974    task: Option<&str>,
975    state: Option<&Path>,
976    options: &RunOptions,
977) -> CommandResult {
978    if slug.is_none()
979        && let Err(r) = precheck(path, task, state)
980    {
981        return *r;
982    }
983    with_model(path, state, |state, provider, key| {
984        run_with(path, slug, task, state, provider, Some(key), options)
985    })
986}
987
988/// [`run()`] against `state` with `provider`.
989#[must_use]
990pub fn run_with(
991    path: &Path,
992    slug: Option<&str>,
993    task: Option<&str>,
994    state: &Path,
995    provider: Arc<dyn ModelProvider>,
996    secret: Option<&str>,
997    options: &RunOptions,
998) -> CommandResult {
999    let (root, format) = match root_of(path) {
1000        Ok(x) => x,
1001        Err(r) => return *r,
1002    };
1003    if let Some(s) = slug.filter(|s| !speccoder_core::bug::valid_slug(s)) {
1004        return invalid_slug(s);
1005    }
1006    let plane = crate::worktree::project_state_dir(state, &root);
1007    let found = match (slug, task) {
1008        (Some(s), _) => format
1009            .bug_unit(&root)
1010            .ok()
1011            .and_then(|h| format.bug_dir(&h, s))
1012            .and_then(|(_, unit)| state::load(&plane, &unit, &format!("bug:{s}"))),
1013        (None, Some(t)) => state::find(&plane, t),
1014        (None, None) => None,
1015    };
1016    match (found, slug) {
1017        (Some(bug), _) => run_bug(&root, &bug, state, Some(provider), secret, options),
1018        // A bug the person assessed with the format's own step: read it in.
1019        (None, Some(s)) => bug_with(
1020            path,
1021            &Request {
1022                slug: Some(s.to_owned()),
1023                run: true,
1024                ..Request::default()
1025            },
1026            Via::Terminal,
1027            state,
1028            Some(provider),
1029            secret,
1030            options,
1031        ),
1032        (None, None) => CommandResult::new(
1033            "bug",
1034            CommandStatus::NotFound,
1035            &format!(
1036                "no bug {}",
1037                task.unwrap_or("named: pass --task <unit>/<task> or --slug <slug>")
1038            ),
1039        ),
1040    }
1041}
1042
1043/// `speccoder bug confirm <unit>/<task> --digest D` (or `--reject`):
1044/// the person's one decision on the reproductions shown; with `run`, the
1045/// fix (or the next try) runs at once.
1046#[must_use]
1047#[allow(clippy::too_many_arguments)]
1048pub fn confirm(
1049    path: &Path,
1050    key: &str,
1051    digest: &str,
1052    reject: Option<&str>,
1053    run: bool,
1054    via: Via,
1055    state: Option<&Path>,
1056    options: &RunOptions,
1057) -> CommandResult {
1058    // What can be refused is refused before a model is opened.
1059    if let Some(r) = precheck(path, Some(key), state).err().or_else(|| {
1060        (reject.is_some_and(|r| r.trim().is_empty())).then(|| {
1061            Box::new(
1062                CommandResult::new(
1063                    "bug",
1064                    CommandStatus::UsageError,
1065                    "a reason is needed: the author's next try is told it",
1066                )
1067                .with_code("USAGE"),
1068            )
1069        })
1070    }) {
1071        return *r;
1072    }
1073    if run {
1074        return with_model(path, state, |state, provider, k| {
1075            confirm_with(
1076                path,
1077                key,
1078                digest,
1079                reject,
1080                via,
1081                state,
1082                Some(provider),
1083                Some(k),
1084                options,
1085            )
1086        });
1087    }
1088    match state_or_env(state) {
1089        Ok(state) => confirm_with(path, key, digest, reject, via, &state, None, None, options),
1090        Err(r) => *r,
1091    }
1092}
1093
1094/// [`confirm()`] against `state`; `provider` runs what follows (none: only
1095/// the decision is recorded).
1096#[allow(clippy::too_many_arguments)]
1097#[must_use]
1098pub fn confirm_with(
1099    path: &Path,
1100    key: &str,
1101    digest: &str,
1102    reject: Option<&str>,
1103    via: Via,
1104    state: &Path,
1105    provider: Option<Arc<dyn ModelProvider>>,
1106    secret: Option<&str>,
1107    options: &RunOptions,
1108) -> CommandResult {
1109    let (root, format) = match root_of(path) {
1110        Ok(x) => x,
1111        Err(r) => return *r,
1112    };
1113    let plane = crate::worktree::project_state_dir(state, &root);
1114    let Some(mut bug) = state::find(&plane, key) else {
1115        return CommandResult::new("bug", CommandStatus::NotFound, &format!("no bug {key}"));
1116    };
1117    if bug.outcome != Outcome::AwaitingConfirmation {
1118        return result(
1119            CommandStatus::UsageError,
1120            &format!(
1121                "{key} is not waiting for a confirmation ({})",
1122                bug.outcome.as_str()
1123            ),
1124            &bug,
1125        );
1126    }
1127    let shown = bug.confirm_digest.clone().unwrap_or_default();
1128    if digest.trim() != shown {
1129        return CommandResult::new(
1130            "bug",
1131            CommandStatus::UsageError,
1132            &format!("the digest does not match the reproductions shown ({shown}): look at them again (`speccoder bug confirm {key}` in a terminal shows them)"),
1133        )
1134        .with_code("USAGE");
1135    }
1136    // The triad as it reads now: a reworded one lapses what was shown.
1137    match crate::pipeline::bug_triad_now(format, &root, &bug) {
1138        Ok(now) if now.digest != bug.triad.digest => {
1139            let _ = confirm::lapse(&plane, &mut bug, now);
1140            return result(
1141                CommandStatus::NeedsHuman,
1142                "the triad was reworded since the reproductions were written: they lapse; reproduce again",
1143                &bug,
1144            );
1145        }
1146        Err(r) => return refuse(&r),
1147        Ok(_) => {}
1148    }
1149    let by = crate::approvals::who_in(&root);
1150    if let Some(reason) = reject {
1151        let reason = reason.trim();
1152        if reason.is_empty() {
1153            return CommandResult::new(
1154                "bug",
1155                CommandStatus::UsageError,
1156                "a reason is needed: the author's next try is told it",
1157            )
1158            .with_code("USAGE");
1159        }
1160        if let Err(e) = crate::pipeline::bug_reject(&plane, &mut bug, reason, via) {
1161            return CommandResult::new(
1162                "bug",
1163                CommandStatus::Error,
1164                &format!("the rejection could not be kept: {e}; nothing was rejected"),
1165            );
1166        }
1167        emit(
1168            options,
1169            &bug.task,
1170            BugEvent::ConfirmRejected {
1171                digest: shown,
1172                reason: reason.to_owned(),
1173            },
1174        );
1175        return match provider {
1176            Some(p) => run_bug(&root, &bug, state, Some(p), secret, options),
1177            None => result(
1178                CommandStatus::Ok,
1179                &format!(
1180                    "rejected; the author is told why on the next try: speccoder bug run --task {key}"
1181                ),
1182                &bug,
1183            ),
1184        };
1185    }
1186    match confirm::freeze(
1187        &plane,
1188        &mut bug,
1189        &by,
1190        via,
1191        speccoder_core::bug::Basis::Shown,
1192    ) {
1193        Ok(_) => {
1194            emit(
1195                options,
1196                &bug.task,
1197                BugEvent::Confirmed {
1198                    digest: shown,
1199                    by: by.clone(),
1200                    via: via.as_str().to_owned(),
1201                    basis: "shown".to_owned(),
1202                },
1203            );
1204            match provider {
1205                Some(p) => run_bug(&root, &bug, state, Some(p), secret, options),
1206                None => result(
1207                    CommandStatus::Ok,
1208                    &format!("confirmed and frozen; fix it: speccoder bug run --task {key}"),
1209                    &bug,
1210                ),
1211            }
1212        }
1213        Err(e) => CommandResult::new(
1214            "bug",
1215            CommandStatus::Error,
1216            &format!("the confirmation could not be recorded: {e}"),
1217        ),
1218    }
1219}
1220
1221/// The bug named `key` exists (no model needed to say it does not).
1222fn precheck(
1223    path: &Path,
1224    key: Option<&str>,
1225    state: Option<&Path>,
1226) -> Result<(), Box<CommandResult>> {
1227    let (root, _) = root_of(path)?;
1228    let Some(key) = key else {
1229        return Err(Box::new(
1230            CommandResult::new(
1231                "bug",
1232                CommandStatus::UsageError,
1233                "name the bug: --task <unit>/<task> or --slug <slug>",
1234            )
1235            .with_code("USAGE"),
1236        ));
1237    };
1238    let state = state_or_env(state)?;
1239    let plane = crate::worktree::project_state_dir(&state, &root);
1240    if state::find(&plane, key).is_none() {
1241        return Err(Box::new(CommandResult::new(
1242            "bug",
1243            CommandStatus::NotFound,
1244            &format!("no bug {key}"),
1245        )));
1246    }
1247    Ok(())
1248}
1249
1250/// The bug named `key`, as speccoder keeps it.
1251#[must_use]
1252pub fn show(path: &Path, key: &str) -> CommandResult {
1253    let Ok((root, _)) = root_of(path) else {
1254        return CommandResult::new("bug", CommandStatus::NotAProject, "not a project");
1255    };
1256    let Some(state) = crate::worktree::state_dir() else {
1257        return CommandResult::new("bug", CommandStatus::ConfigError, "no state directory")
1258            .with_code("NO-STATE-DIR");
1259    };
1260    let plane = crate::worktree::project_state_dir(&state, &root);
1261    match state::find(&plane, key) {
1262        Some(b) => result(
1263            CommandStatus::Ok,
1264            &format!("{key}: {}", b.outcome.as_str()),
1265            &b,
1266        ),
1267        None => CommandResult::new("bug", CommandStatus::NotFound, &format!("no bug {key}")),
1268    }
1269}
1270
1271/// What a person is shown before confirming `key` in a terminal.
1272#[must_use]
1273pub fn shown(state: &Path, root: &Path, key: &str) -> Option<crate::approvals::ConfirmRequest> {
1274    let plane = crate::worktree::project_state_dir(state, root);
1275    let bug = state::find(&plane, key)?;
1276    let digest = bug.confirm_digest.clone()?;
1277    let paths: Vec<String> = bug.reproductions.iter().map(|r| r.file.clone()).collect();
1278    let files = state::repro(&plane, &bug.unit, &bug.task, &paths);
1279    Some(crate::approvals::ConfirmRequest {
1280        bug: bug.key(),
1281        title: bug.title.clone(),
1282        digest,
1283        reproductions: bug
1284            .reproductions
1285            .iter()
1286            .map(|r| crate::approvals::ShownReproduction {
1287                statement: r.statement.clone(),
1288                words: bug
1289                    .triad
1290                    .statement(&r.statement)
1291                    .map(|s| s.text.clone())
1292                    .unwrap_or_default(),
1293                file: r.file.clone(),
1294                test: r.test.clone(),
1295                argv: r.argv.clone(),
1296                excerpt: String::new(),
1297                content: files
1298                    .iter()
1299                    .find(|(p, _)| *p == r.file)
1300                    .map(|(_, c)| c.clone())
1301                    .unwrap_or_default(),
1302            })
1303            .collect(),
1304        warning: held_out_warning(&bug),
1305    })
1306}
1307
1308/// What a person confirming `bug` must know first: no held-out variant
1309/// could be kept, so the fix cannot be verified.
1310#[must_use]
1311pub fn held_out_warning(bug: &BugReport) -> Option<String> {
1312    bug.held_out_reason.as_ref().map(|why| {
1313        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")
1314    })
1315}
1316
1317/// The bug a unit is (the format's bug spec), as the report shows it: the
1318/// one speccoder keeps, or one not run yet.
1319#[must_use]
1320pub fn of_unit(
1321    snapshot: &speccoder_core::snapshot::ProjectSnapshot,
1322) -> Option<speccoder_core::UnitBug> {
1323    of_unit_in(snapshot, crate::worktree::state_dir().as_deref())
1324}
1325
1326/// [`of_unit`] with speccoder's state in `state`.
1327#[must_use]
1328pub fn of_unit_in(
1329    snapshot: &speccoder_core::snapshot::ProjectSnapshot,
1330    state: Option<&Path>,
1331) -> Option<speccoder_core::UnitBug> {
1332    let format = crate::format::of(snapshot);
1333    let slug = format.bug_spec(snapshot)?;
1334    let root = Path::new(&snapshot.root);
1335    let (_, unit) = format.bug_dir(&format.bug_unit(root).ok()?, &slug)?;
1336    let task = format!("bug:{slug}");
1337    let kept =
1338        state.and_then(|s| state::load(&crate::worktree::project_state_dir(s, root), &unit, &task));
1339    Some(speccoder_core::UnitBug {
1340        key: format!("{unit}/{task}"),
1341        title: kept
1342            .as_ref()
1343            .map_or_else(|| speccoder_core::words::titled(&slug), |b| b.title.clone()),
1344        stage: kept.as_ref().map_or(Stage::Record, |b| b.stage),
1345        verdict: kept.as_ref().and_then(|b| b.verdict.clone()),
1346        outcome: kept.map(|b| b.outcome),
1347    })
1348}