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() && format.bug_step().is_some())
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(r) => Err(Box::new(CommandResult::new("bug", r.status, &r.message))),
197 }
198}
199
200fn 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
208fn 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#[allow(clippy::too_many_lines)] #[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 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 if home.kind.native() && format.bug_step().is_none() {
383 return refuse(&writes_its_own(&home));
384 }
385
386 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 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 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 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 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 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 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
734fn 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
745fn 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#[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 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#[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#[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#[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 (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#[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 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#[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 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
1221fn 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#[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#[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#[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#[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#[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}