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