1use std::path::Path;
36
37use speccoder_core::actions::{Action, ActionKind, Runnable, Via};
38use speccoder_core::{ApprovalsReport, CommandData, CommandResult, CommandStatus, StatusReport};
39
40use crate::RunOptions;
41
42pub(crate) fn action(
43 group: &str,
44 label: impl Into<String>,
45 why: impl Into<String>,
46 command: impl Into<String>,
47) -> Action {
48 Action::new(group, label, why, command)
49}
50
51#[must_use]
54pub fn available(path: &Path, feature: Option<&str>, selected: Option<&str>) -> Vec<Action> {
55 available_with(path, feature, selected, None)
56}
57
58#[must_use]
61pub fn available_with(
62 path: &Path,
63 feature: Option<&str>,
64 selected: Option<&str>,
65 listed: Option<&ApprovalsReport>,
66) -> Vec<Action> {
67 let report = crate::status::report(path, feature);
68 let report: Option<&StatusReport> = match report.data() {
69 Some(CommandData::Report(r)) => Some(r),
70 _ => None,
71 };
72 let approvals = listed.cloned().or_else(|| {
73 crate::worktree::state_dir().and_then(|state| {
74 match crate::approvals::list_with(path, feature, &state).data() {
75 Some(CommandData::Approvals(a)) => Some((**a).clone()),
76 _ => None,
77 }
78 })
79 });
80 let uncommitted = crate::hosting::uncommitted(path, feature);
81 let skills = match crate::hosting::list(path).data() {
82 Some(CommandData::Skills(s)) => s.skills.clone(),
83 _ => Vec::new(),
84 };
85 let format = crate::format::at(path);
86 let planning = format.planning();
87 let unit = format.messages().unit;
88 let features = crate::current::list(path, None);
89 let feature_count = match features.data() {
90 Some(CommandData::Features(f)) => f.features.len(),
91 _ => 0,
92 };
93 let short = |name: &str| {
94 name.strip_prefix(planning.prefix)
95 .unwrap_or(name)
96 .to_owned()
97 };
98 let skill = |group: &str, name: &str, why: &str| {
99 let creates = planning.creates.contains(&name);
100 let mut a = action(
101 group,
102 format!("spec {name}"),
103 why,
104 if creates {
105 format!("speccoder spec {name} \"…\"")
106 } else {
107 format!("speccoder spec {name}")
108 },
109 );
110 a = a
111 .with_run(Runnable::Skill {
112 name: name.to_owned(),
113 arguments: String::new(),
114 })
115 .with_role(planning.role_of(name));
116 if creates {
117 a.input = Some(format!("Describe the {unit}"));
118 }
119 a
120 };
121 let mut out = Vec::new();
122
123 for p in approvals.iter().flat_map(|a: &ApprovalsReport| &a.pending) {
125 let mut a = action(
126 "next",
127 format!("approve {}'s waiting call", p.task),
128 format!("{} — {}", p.covers, p.reason),
129 format!("speccoder task approve {}", p.task),
130 );
131 a = a.with_run(Runnable::Approve {
132 task: p.task.clone(),
133 call: p.id(),
134 digest: p.digest.clone(),
135 });
136 out.push(a);
137 let mut r = action(
138 "next",
139 format!("reject {}'s waiting call", p.task),
140 "the model is told your reason",
141 format!("speccoder task reject {} --reason \"…\"", p.task),
142 );
143 r = r.with_run(Runnable::Reject {
144 task: p.task.clone(),
145 call: p.id(),
146 digest: p.digest.clone(),
147 });
148 r.input = Some("Why (the model is told)".to_owned());
149 out.push(r);
150 }
151 let planning_next: Vec<String> = report.map(|r| r.planning.clone()).unwrap_or_default();
152 for name in &planning_next {
153 let why = match name.as_str() {
154 "plan" => "write the plan from the spec",
155 "tasks" => "break the plan into tasks",
156 _ => "the next planning step",
157 };
158 out.push(skill("next", name, why));
159 }
160 if !uncommitted.is_empty() {
161 let mut a = action(
162 "next",
163 format!("commit the planning files ({})", uncommitted.len()),
164 "speccoder runs from committed files",
165 format!(
166 "git add {0} && git commit -m \"spec: …\" -- {0}",
167 uncommitted.join(" ")
168 ),
169 );
170 a = a.with_run(Runnable::Commit {
171 paths: uncommitted.clone(),
172 });
173 out.push(a);
174 }
175 let ready = report.map_or(0, |r| r.totals.ready);
176 let run_blocked = (!uncommitted.is_empty())
177 .then(|| "commit the planning files first: speccoder runs from committed files".to_owned());
178 if ready > 0 && planning_next.is_empty() {
179 let mut a = action(
180 "next",
181 "run the feature",
182 format!("{ready} ready task(s), each judged on evidence"),
183 "speccoder run",
184 );
185 a = a.with_run(Runnable::Run { task: None });
186 a.blocked = run_blocked.clone();
187 out.push(a);
188 }
189 let stale: Vec<&str> = report
192 .map(|r| {
193 r.tasks
194 .iter()
195 .filter(|t| t.drift.as_ref().is_some_and(speccoder_core::Drift::stale))
196 .map(|t| t.id.as_str())
197 .collect()
198 })
199 .unwrap_or_default();
200 if !stale.is_empty() && planning_next.is_empty() {
201 let mut a = action(
202 "next",
203 "re-check what the spec changed",
204 format!(
205 "{} was proven against an earlier wording; convergence assesses the code against the spec as it is now and adds tasks for what no longer holds",
206 stale.join(", ")
207 ),
208 "speccoder run --converge",
209 );
210 a = a.with_run(Runnable::Converge);
211 a.blocked = run_blocked.clone();
212 out.push(a);
213 }
214 if report.is_none()
215 && feature_count == 0
216 && format.detect(path).is_some()
217 && let Some(create) = planning.creates.first()
218 {
219 out.push(skill(
220 "next",
221 create,
222 &format!("write the first {unit}'s spec"),
223 ));
224 }
225
226 for s in skills.iter().filter(|s| s.refused.is_none()) {
228 let name = short(&s.name);
229 if planning_next.contains(&name) {
230 continue;
231 }
232 out.push(skill("plan", &name, &s.description));
233 }
234
235 if let Some(t) =
237 selected.and_then(|id| report.and_then(|r| r.tasks.iter().find(|t| t.id == id)))
238 {
239 if t.state == "ready" {
240 let mut a = action(
241 "work",
242 format!("run {}", t.id),
243 t.title.clone(),
244 format!("speccoder run --task {}", t.id),
245 );
246 a = a.with_run(Runnable::Run {
247 task: Some(t.id.clone()),
248 });
249 a.blocked = run_blocked.clone();
250 out.push(a);
251 }
252 if !matches!(
254 t.state.as_str(),
255 "verified" | "regression_only" | "attested"
256 ) {
257 for (verb, why, prompt, run) in [
258 (
259 "accept",
260 "tick it on your word (attested, not verified)",
261 "Why it is done",
262 Runnable::Accept { task: t.id.clone() },
263 ),
264 (
265 "skip",
266 "runs pass it by; what depends on it stays blocked",
267 "Why it is skipped",
268 Runnable::Skip { task: t.id.clone() },
269 ),
270 (
271 "nudge",
272 "a note its next attempts read",
273 "The note",
274 Runnable::Nudge { task: t.id.clone() },
275 ),
276 ] {
277 let flag = if verb == "nudge" {
278 "--note"
279 } else {
280 "--reason"
281 };
282 let mut a = action(
283 "task",
284 format!("{verb} {}", t.id),
285 why,
286 format!("speccoder task {verb} {} {flag} \"…\"", t.id),
287 );
288 a = a.with_run(run);
289 a.input = Some(prompt.to_owned());
290 out.push(a);
291 }
292 }
293 }
294 if ready > 0 && !planning_next.is_empty() {
295 let mut a = action(
297 "work",
298 "run the feature",
299 format!("{ready} ready task(s)"),
300 "speccoder run",
301 );
302 a = a.with_run(Runnable::Run { task: None });
303 a.blocked = run_blocked;
304 out.push(a);
305 }
306
307 if feature_count > 1 {
309 let mut a = action(
310 "feature",
311 format!("switch the {unit}"),
312 format!("{feature_count} {unit}s"),
313 "speccoder feature <name>",
314 );
315 a = a.with_run(Runnable::Feature);
316 out.push(a);
317 }
318
319 let mut pr = action(
322 "review",
323 "open a draft pull request",
324 "push the branch and its evidence; open (or update) the draft",
325 "speccoder pr --yes",
326 );
327 pr = pr.with_run(Runnable::PullRequest);
328 pr.confirm = Some(
329 "Push the feature's speccoder branch and its attestation notes to `origin`, and open (or update) its draft pull request on GitHub, as you (your token).".to_owned(),
330 );
331 out.push(pr);
332 out.push(action(
333 "review",
334 "verify the attestations",
335 "every tick against its commit and signature",
336 "speccoder attest verify",
337 ));
338 out.push(action(
339 "review",
340 "check the setup",
341 "git, the project, the config, the model key",
342 "speccoder doctor",
343 ));
344 out
345}
346
347#[must_use]
352pub fn list(path: &Path, feature: Option<&str>, selected: Option<&str>) -> CommandResult {
353 let report = || speccoder_core::ActionsReport {
354 actions: Vec::new(),
355 journey: journey(path, feature),
356 };
357 if crate::format::at(path).detect(path).is_none() {
358 let status = crate::status::report(path, feature);
359 return CommandResult::carried("actions", &status)
360 .with_data(CommandData::Actions(Box::new(report())));
361 }
362 let mut r = report();
363 r.actions = available(path, feature, selected);
364 let message = match speccoder_core::actions::primary(&r.actions) {
365 Some(a) => format!("{} action(s); next: {}", r.actions.len(), a.label),
366 None => format!("{} action(s)", r.actions.len()),
367 };
368 CommandResult::ok("actions", &message).with_data(CommandData::Actions(Box::new(r)))
369}
370
371#[must_use]
374pub fn journey(path: &Path, feature: Option<&str>) -> Vec<speccoder_core::actions::JourneyStep> {
375 match crate::status::report(path, feature).data() {
376 Some(CommandData::Report(r)) => r.journey.clone(),
377 _ => speccoder_core::actions::journey(speccoder_core::actions::JourneyFacts::default()),
378 }
379}
380
381#[derive(Debug, Clone, Copy, PartialEq, Eq)]
383pub enum Interface {
384 WebJobs,
386 CliFollow,
388 CliRemedy,
390 AcpRemedy,
392 Dashboard,
394}
395
396impl Interface {
397 pub const ALL: &'static [Self] = &[
399 Self::WebJobs,
400 Self::CliFollow,
401 Self::CliRemedy,
402 Self::AcpRemedy,
403 Self::Dashboard,
404 ];
405
406 #[must_use]
408 pub fn refusal(self) -> CommandResult {
409 match self {
410 Self::CliFollow => CommandResult::new(
411 "next",
412 CommandStatus::UsageError,
413 "not a step speccoder takes here",
414 ),
415 Self::Dashboard => CommandResult::new(
416 "tui",
417 CommandStatus::UsageError,
418 "not something the dashboard does",
419 ),
420 Self::WebJobs => CommandResult::new(
421 "job",
422 CommandStatus::UsageError,
423 "not something the web app runs",
424 ),
425 Self::CliRemedy | Self::AcpRemedy => CommandResult::new(
426 "fix",
427 CommandStatus::UsageError,
428 "not a fix speccoder applies here",
429 ),
430 }
431 }
432}
433
434#[derive(Debug, Clone, Copy, PartialEq, Eq)]
436pub enum Offer {
437 Take,
439 Refuse,
441}
442
443#[must_use]
446pub const fn offered(interface: Interface, kind: ActionKind) -> Offer {
447 use ActionKind as K;
448 match interface {
449 Interface::WebJobs => match kind {
450 K::Skill
451 | K::RunTask
452 | K::RunFeature
453 | K::Converge
454 | K::Commit
455 | K::Approve
456 | K::Reject
457 | K::Accept
458 | K::Skip
459 | K::Nudge
460 | K::Rule
461 | K::Review
462 | K::IssueComment
463 | K::QuickChange
464 | K::KeepSetAside
465 | K::DropSetAside
466 | K::FixSetup
467 | K::Feature
468 | K::PullRequest
469 | K::BugFix
470 | K::BugConfirm
471 | K::BugRun
472 | K::BugKeep
473 | K::AllowAlways
474 | K::WidenPolicy
475 | K::DecideBatch
476 | K::OracleTests
477 | K::ApproveAndRun
478 | K::Comment
479 | K::CommentsSend
480 | K::RevisionUndo
481 | K::CodeNudge => Offer::Take,
482 K::Command => Offer::Refuse,
483 },
484 Interface::CliFollow => match kind {
487 K::Skill | K::RunTask | K::RunFeature | K::Converge | K::Commit => Offer::Take,
488 K::Approve
489 | K::Reject
490 | K::Accept
491 | K::Skip
492 | K::Nudge
493 | K::Rule
494 | K::Review
495 | K::IssueComment
496 | K::QuickChange
497 | K::KeepSetAside
498 | K::DropSetAside
499 | K::FixSetup
500 | K::Feature
501 | K::PullRequest
502 | K::BugFix
503 | K::BugConfirm
504 | K::BugRun
505 | K::BugKeep
506 | K::AllowAlways
507 | K::WidenPolicy
508 | K::DecideBatch
509 | K::OracleTests
510 | K::ApproveAndRun
511 | K::Comment
512 | K::CommentsSend
513 | K::RevisionUndo
514 | K::CodeNudge
515 | K::Command => Offer::Refuse,
516 },
517 Interface::Dashboard => match kind {
520 K::Approve | K::Reject | K::Feature | K::AllowAlways | K::DecideBatch => Offer::Take,
521 K::Skill
522 | K::RunTask
523 | K::RunFeature
524 | K::Converge
525 | K::Commit
526 | K::Accept
527 | K::Skip
528 | K::Nudge
529 | K::Rule
530 | K::Review
531 | K::IssueComment
532 | K::QuickChange
533 | K::KeepSetAside
534 | K::DropSetAside
535 | K::FixSetup
536 | K::PullRequest
537 | K::BugFix
538 | K::BugConfirm
539 | K::BugRun
540 | K::BugKeep
541 | K::WidenPolicy
542 | K::OracleTests
543 | K::ApproveAndRun
544 | K::Comment
545 | K::CommentsSend
546 | K::RevisionUndo
547 | K::CodeNudge
548 | K::Command => Offer::Refuse,
549 },
550 Interface::CliRemedy | Interface::AcpRemedy => match kind {
551 K::FixSetup => Offer::Take,
552 K::Skill
553 | K::RunTask
554 | K::RunFeature
555 | K::Converge
556 | K::Commit
557 | K::Approve
558 | K::Reject
559 | K::Accept
560 | K::Skip
561 | K::Nudge
562 | K::Rule
563 | K::Review
564 | K::IssueComment
565 | K::QuickChange
566 | K::KeepSetAside
567 | K::DropSetAside
568 | K::Feature
569 | K::PullRequest
570 | K::BugFix
571 | K::BugConfirm
572 | K::BugRun
573 | K::BugKeep
574 | K::AllowAlways
575 | K::WidenPolicy
576 | K::DecideBatch
577 | K::OracleTests
578 | K::ApproveAndRun
579 | K::Comment
580 | K::CommentsSend
581 | K::RevisionUndo
582 | K::CodeNudge
583 | K::Command => Offer::Refuse,
584 },
585 }
586}
587
588#[derive(Debug)]
592pub struct Dispatch<'a> {
593 pub run: &'a Runnable,
595 pub input: Option<&'a str>,
598 pub via: Via,
600 pub options: RunOptions,
602}
603
604impl<'a> Dispatch<'a> {
605 #[must_use]
607 pub fn new(run: &'a Runnable, via: Via) -> Self {
608 Self {
609 run,
610 input: None,
611 via,
612 options: RunOptions::default(),
613 }
614 }
615}
616
617#[must_use]
620pub fn perform_for(
621 interface: Interface,
622 root: &Path,
623 feature: Option<&str>,
624 d: Dispatch<'_>,
625) -> Option<CommandResult> {
626 (offered(interface, d.run.kind()) == Offer::Take).then(|| perform(root, feature, d))
627}
628
629#[must_use]
634pub fn perform(root: &Path, feature: Option<&str>, d: Dispatch<'_>) -> CommandResult {
635 perform_in(root, feature, d, crate::worktree::state_dir().as_deref())
636}
637
638#[must_use]
640pub fn perform_in(
641 root: &Path,
642 feature: Option<&str>,
643 d: Dispatch<'_>,
644 state: Option<&Path>,
645) -> CommandResult {
646 use crate::approvals::{Choice, Verb};
647 let needs = |what: &str| {
648 CommandResult::new(
649 "job",
650 CommandStatus::UsageError,
651 &format!("{what} is needed"),
652 )
653 };
654 let no_state = || CommandResult::new("job", CommandStatus::ConfigError, "no state directory");
655 let decide = |verb| crate::approvals::decide_in(root, feature, verb, state, d.via);
656 let options = &d.options;
657 let via = d.via;
658 let text = d.input.map(str::trim).filter(|t| !t.is_empty());
659 match d.run {
660 Runnable::Skill { name, arguments } => skill(root, feature, name, text, arguments, options),
661 Runnable::Run { task: Some(t) } => run_task(root, feature, t, options),
662 Runnable::Run { task: None } => run_feature(root, feature, options, false),
663 Runnable::Converge => run_feature(root, feature, options, true),
664 Runnable::Commit { paths } => crate::hosting::commit_planning(root, feature, paths),
665 Runnable::Approve { task, call, digest } => decide(Verb::Decide(
666 Choice::approve(task, text).shown(call, digest),
667 )),
668 Runnable::Reject { task, call, digest } => match text {
669 Some(why) => decide(Verb::Decide(Choice::reject(task, why).shown(call, digest))),
670 None => needs("a reason"),
671 },
672 Runnable::Accept { task } => match (text, state) {
673 (Some(why), Some(s)) => crate::controls::accept_in(root, feature, task, why, s, false),
674 (None, _) => needs("a reason"),
675 (_, None) => no_state(),
676 },
677 Runnable::Skip { task } => match text {
678 Some(why) => crate::controls::skip(root, feature, task, Some(why)),
679 None => needs("a reason"),
680 },
681 Runnable::Nudge { task } => match (text, state) {
682 (Some(note), Some(s)) => {
683 crate::controls::nudge_with(root, feature, task, Some(note), s)
684 }
685 (None, _) => needs("a note"),
686 (_, None) => no_state(),
687 },
688 Runnable::Rule { task } => match (text, state) {
689 (Some(why), Some(s)) => crate::controls::rule(root, feature, task, why, None, s),
690 (None, _) => needs("a reason"),
691 (_, None) => no_state(),
692 },
693 Runnable::Review { ids, verdict } => match (verdict.as_str(), ids.first(), text) {
694 ("answer", Some(q), Some(t)) => crate::review::answer(root, feature, q, t),
695 ("edit" | "edit-approve", Some(id), Some(t)) => {
696 crate::review::edit(root, feature, id, t, verdict == "edit-approve", via)
697 }
698 ("answer" | "edit" | "edit-approve", _, _) => needs("the words"),
699 _ => crate::review::decide(root, feature, ids, verdict, text, via),
700 },
701 Runnable::IssueComment { issue, assign } => crate::github::comment(
702 &crate::github::Ctx::from_env(),
703 root,
704 feature,
705 &issue.to_string(),
706 *assign,
707 ),
708 Runnable::QuickChange {
709 request,
710 checks,
711 run,
712 } => quick_change(root, request, checks, *run, via, options),
713 Runnable::KeepSetAside { task } => crate::controls::restore(root, feature, task),
714 Runnable::DropSetAside { task } => crate::controls::drop_set_aside(root, feature, task),
715 Runnable::FixSetup => crate::code::fix_setup(root, feature),
716 Runnable::Feature => match text {
717 Some(name) => crate::current::switch(root, name),
718 None => needs("a feature"),
719 },
720 Runnable::BugFix {
721 symptom,
722 expected,
723 unchanged,
724 issue,
725 tests,
726 candidate,
727 slug,
728 run,
729 } => bug_fix(
730 root,
731 &crate::bug::Request::of(
732 symptom,
733 expected,
734 unchanged,
735 *issue,
736 tests,
737 candidate.as_deref(),
738 slug.as_deref(),
739 *run,
740 ),
741 via,
742 state,
743 options,
744 ),
745 Runnable::BugConfirm {
746 task,
747 digest,
748 reject,
749 } => match (*reject, text) {
750 (true, None) => needs("a reason"),
751 (true, Some(why)) => bug_confirm(root, task, digest, Some(why), via, state, options),
752 (false, _) => bug_confirm(root, task, digest, None, via, state, options),
753 },
754 Runnable::BugRun { slug, task } => {
755 bug_run(root, slug.as_deref(), task.as_deref(), state, options)
756 }
757 Runnable::BugKeep { task } => match text {
758 Some(why) => crate::bug::keep(root, task, why, state),
759 None => needs("a reason"),
760 },
761 Runnable::AllowAlways { task, call } => {
762 crate::approvals::allow_always(root, feature, task, call, state, via)
763 }
764 Runnable::WidenPolicy {
765 key,
766 prefix,
767 digest,
768 } => crate::approvals::coverage::widen(root, state, *key, prefix, digest, via),
769 Runnable::DecideBatch {
770 run,
771 verdict,
772 calls,
773 digest,
774 resume_asking,
775 } => {
776 let approve = *verdict == speccoder_core::actions::BatchVerdict::Approve;
777 if !approve && text.is_none() {
778 return needs("a reason");
779 }
780 crate::approvals::decide_batch(
781 root,
782 feature,
783 crate::approvals::BatchChoice {
784 run,
785 approve,
786 calls,
787 digest,
788 resume_asking: *resume_asking,
789 reason: text,
790 },
791 state,
792 via,
793 )
794 }
795 Runnable::OracleTests {
796 req,
797 op,
798 path,
799 scenario,
800 reading,
801 } => crate::oracle::tests(
802 root,
803 feature,
804 &crate::oracle::Request {
805 req,
806 op: *op,
807 path: path.as_deref(),
808 scenario: scenario.as_deref(),
809 reading: *reading,
810 text: d.input.filter(|t| !t.trim().is_empty()),
811 },
812 via,
813 state,
814 options,
815 ),
816 Runnable::ApproveAndRun {
817 mode,
818 grants,
819 sheet,
820 } => crate::review::approve_and_run(
821 root,
822 feature,
823 *mode,
824 grants,
825 sheet.as_deref(),
826 via,
827 state,
828 None,
829 options,
830 ),
831 Runnable::Comment { op, anchor, id } => crate::comments::comment(
832 root,
833 feature,
834 *op,
835 anchor.as_ref(),
836 id.as_deref(),
837 text,
838 via,
839 state,
840 ),
841 Runnable::CommentsSend { ids } => {
842 crate::comments::send(root, feature, ids, via, state, options)
843 }
844 Runnable::RevisionUndo { turn } => {
845 crate::comments::undo(root, feature, turn.as_deref(), state)
846 }
847 Runnable::CodeNudge { task, comments } => {
848 crate::comments::code_nudge(root, feature, task, comments, state)
849 }
850 Runnable::PullRequest => crate::handoff::pr(
853 root,
854 feature,
855 &crate::handoff::PrOptions {
856 remote: "origin",
857 base: None,
858 open: true,
859 force: false,
860 github: &crate::github::Ctx::from_env(),
861 },
862 ),
863 }
864}
865
866#[must_use]
870pub fn allow_always_inline(
871 root: &Path,
872 feature: &str,
873 always: &[crate::substrate::Always],
874 state: &Path,
875) -> Vec<String> {
876 always
877 .iter()
878 .map(|a| {
879 let task = a.task.rsplit('/').next().unwrap_or(&a.task).to_owned();
880 let run = Runnable::AllowAlways {
881 task,
882 call: a.call.clone(),
883 };
884 perform_in(root, Some(feature), Dispatch::new(&run, a.via), Some(state))
885 .message()
886 .to_owned()
887 })
888 .collect()
889}
890
891fn skill(
896 root: &Path,
897 feature: Option<&str>,
898 name: &str,
899 input: Option<&str>,
900 carried: &str,
901 options: &RunOptions,
902) -> CommandResult {
903 crate::hosting::run(root, feature, name, input.unwrap_or(carried), options)
904}
905
906fn run_task(root: &Path, feature: Option<&str>, task: &str, options: &RunOptions) -> CommandResult {
907 crate::run(root, feature, task, options)
908}
909
910fn run_feature(
911 root: &Path,
912 feature: Option<&str>,
913 options: &RunOptions,
914 converge: bool,
915) -> CommandResult {
916 crate::run_feature(root, feature, options, converge)
917}
918
919fn bug_fix(
920 root: &Path,
921 req: &crate::bug::Request,
922 via: Via,
923 state: Option<&Path>,
924 options: &RunOptions,
925) -> CommandResult {
926 crate::bug::bug(root, req, via, state, options)
927}
928
929fn bug_confirm(
930 root: &Path,
931 task: &str,
932 digest: &str,
933 reject: Option<&str>,
934 via: Via,
935 state: Option<&Path>,
936 options: &RunOptions,
937) -> CommandResult {
938 crate::bug::confirm(root, task, digest, reject, true, via, state, options)
939}
940
941fn bug_run(
942 root: &Path,
943 slug: Option<&str>,
944 task: Option<&str>,
945 state: Option<&Path>,
946 options: &RunOptions,
947) -> CommandResult {
948 crate::bug::run(root, slug, task, state, options)
949}
950
951fn quick_change(
952 root: &Path,
953 request: &str,
954 checks: &[String],
955 run: bool,
956 via: Via,
957 options: &RunOptions,
958) -> CommandResult {
959 crate::quick::change(root, request, checks, run, via, options)
960}
961
962#[cfg(test)]
963mod tests {
964 use speccoder_core::actions::primary;
965
966 use super::*;
967
968 #[test]
969 fn every_interface_decides_every_kind() {
970 let mut taken = 0;
971 for i in Interface::ALL {
972 for k in ActionKind::ALL {
973 if offered(*i, *k) == Offer::Take {
974 taken += 1;
975 } else {
976 assert_ne!(i.refusal().status(), CommandStatus::Ok, "{i:?}");
977 }
978 }
979 }
980 let runnable = Runnable::examples().len();
983 assert_eq!(taken, runnable + 5 + 5 + 2);
985 assert_eq!(
986 offered(Interface::CliFollow, ActionKind::PullRequest),
987 Offer::Refuse
988 );
989 assert_eq!(
990 offered(Interface::AcpRemedy, ActionKind::FixSetup),
991 Offer::Take
992 );
993 }
994
995 #[test]
996 fn blank_input_is_no_input() {
997 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
998 let run = Runnable::Reject {
999 task: "T001".to_owned(),
1000 call: "attempt:T001".to_owned(),
1001 digest: String::new(),
1002 };
1003 let r = perform_in(
1004 dir.path(),
1005 None,
1006 Dispatch {
1007 run: &run,
1008 input: Some(" "),
1009 via: Via::Web,
1010 options: RunOptions::default(),
1011 },
1012 None,
1013 );
1014 assert_eq!(
1015 (r.command(), r.status(), r.message()),
1016 ("job", CommandStatus::UsageError, "a reason is needed")
1017 );
1018 }
1019
1020 #[test]
1021 fn a_project_outside_git_still_gets_a_list() {
1022 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1023 let list = available(dir.path(), None, None);
1024 assert!(list.iter().any(|a| a.command == "speccoder doctor"));
1025 assert!(primary(&list).is_none());
1026 }
1027}