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