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 if let Some(first) = planning_next.first() {
155 let mut a = action(
156 "next",
157 "plan through to tasks",
158 "the format's own skills in order; it stops for open questions and your review",
159 format!("speccoder spec {first} --through tasks"),
160 );
161 a = a.with_run(Runnable::PlanThrough {
162 from: speccoder_core::actions::PlanFrom::Continue,
163 through: speccoder_core::actions::Through::Tasks,
164 });
165 let gate = crate::review::summary(path, feature).and_then(|r| {
166 if !r.questions.is_empty() {
167 Some(format!(
168 "answer the {} open question{} first",
169 r.questions.len(),
170 if r.questions.len() == 1 { "" } else { "s" }
171 ))
172 } else {
173 (!r.requirements.is_empty() && !r.totals.complete())
174 .then(|| "approve the spec's requirements first (Review)".to_owned())
175 }
176 });
177 a.blocked = gate;
178 out.push(a);
179 }
180 for name in &planning_next {
181 let why = match name.as_str() {
182 "plan" => "write the plan from the spec",
183 "tasks" => "break the plan into tasks",
184 _ => "the next planning step",
185 };
186 out.push(skill("next", name, why));
187 }
188 if !uncommitted.is_empty() {
189 let mut a = action(
190 "next",
191 format!("commit the planning files ({})", uncommitted.len()),
192 "speccoder runs from committed files",
193 format!(
194 "git add {0} && git commit -m \"spec: …\" -- {0}",
195 uncommitted.join(" ")
196 ),
197 );
198 a = a.with_run(Runnable::Commit {
199 paths: uncommitted.clone(),
200 });
201 out.push(a);
202 }
203 let ready = report.map_or(0, |r| r.totals.ready);
204 let run_blocked = (!uncommitted.is_empty())
205 .then(|| "commit the planning files first: speccoder runs from committed files".to_owned());
206 if ready > 0 && planning_next.is_empty() {
207 let mut a = action(
208 "next",
209 "run the feature",
210 format!("{ready} ready task(s), each judged on evidence"),
211 "speccoder run",
212 );
213 a = a.with_run(Runnable::Run { task: None });
214 a.blocked = run_blocked.clone();
215 out.push(a);
216 }
217 let stale: Vec<&str> = report
220 .map(|r| {
221 r.tasks
222 .iter()
223 .filter(|t| t.drift.as_ref().is_some_and(speccoder_core::Drift::stale))
224 .map(|t| t.id.as_str())
225 .collect()
226 })
227 .unwrap_or_default();
228 if !stale.is_empty() && planning_next.is_empty() {
229 let mut a = action(
230 "next",
231 "re-check what the spec changed",
232 format!(
233 "{} 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",
234 stale.join(", ")
235 ),
236 "speccoder run --converge",
237 );
238 a = a.with_run(Runnable::Converge);
239 a.blocked = run_blocked.clone();
240 out.push(a);
241 }
242 if report.is_none()
243 && feature_count == 0
244 && format.detect(path).is_some()
245 && let Some(create) = planning.creates.first()
246 {
247 out.push(skill(
248 "next",
249 create,
250 &format!("write the first {unit}'s spec"),
251 ));
252 }
253
254 for s in skills.iter().filter(|s| s.refused.is_none()) {
256 let name = short(&s.name);
257 if planning_next.contains(&name) {
258 continue;
259 }
260 out.push(skill("plan", &name, &s.description));
261 }
262
263 if let Some(t) =
265 selected.and_then(|id| report.and_then(|r| r.tasks.iter().find(|t| t.id == id)))
266 {
267 if t.state == "ready" {
268 let mut a = action(
269 "work",
270 format!("run {}", t.id),
271 t.title.clone(),
272 format!("speccoder run --task {}", t.id),
273 );
274 a = a.with_run(Runnable::Run {
275 task: Some(t.id.clone()),
276 });
277 a.blocked = run_blocked.clone();
278 out.push(a);
279 }
280 if !matches!(
282 t.state.as_str(),
283 "verified" | "regression_only" | "attested"
284 ) {
285 for (verb, why, prompt, run) in [
286 (
287 "accept",
288 "tick it on your word (attested, not verified)",
289 "Why it is done",
290 Runnable::Accept { task: t.id.clone() },
291 ),
292 (
293 "skip",
294 "runs pass it by; what depends on it stays blocked",
295 "Why it is skipped",
296 Runnable::Skip { task: t.id.clone() },
297 ),
298 (
299 "nudge",
300 "a note its next attempts read",
301 "The note",
302 Runnable::Nudge { task: t.id.clone() },
303 ),
304 ] {
305 let flag = if verb == "nudge" {
306 "--note"
307 } else {
308 "--reason"
309 };
310 let mut a = action(
311 "task",
312 format!("{verb} {}", t.id),
313 why,
314 format!("speccoder task {verb} {} {flag} \"…\"", t.id),
315 );
316 a = a.with_run(run);
317 a.input = Some(prompt.to_owned());
318 out.push(a);
319 }
320 }
321 }
322 if ready > 0 && !planning_next.is_empty() {
323 let mut a = action(
325 "work",
326 "run the feature",
327 format!("{ready} ready task(s)"),
328 "speccoder run",
329 );
330 a = a.with_run(Runnable::Run { task: None });
331 a.blocked = run_blocked;
332 out.push(a);
333 }
334
335 if feature_count > 1 {
337 let mut a = action(
338 "feature",
339 format!("switch the {unit}"),
340 format!("{feature_count} {unit}s"),
341 "speccoder feature <name>",
342 );
343 a = a.with_run(Runnable::Feature);
344 out.push(a);
345 }
346
347 let mut pr = action(
350 "review",
351 "open a draft pull request",
352 "push the branch and its evidence; open (or update) the draft",
353 "speccoder pr --yes",
354 );
355 pr = pr.with_run(Runnable::PullRequest);
356 pr.confirm = Some(
357 "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(),
358 );
359 out.push(pr);
360 out.push(action(
361 "review",
362 "verify the attestations",
363 "every tick against its commit and signature",
364 "speccoder attest verify",
365 ));
366 out.push(action(
367 "review",
368 "check the setup",
369 "git, the project, the config, the model key",
370 "speccoder doctor",
371 ));
372 out
373}
374
375#[must_use]
380pub fn list(path: &Path, feature: Option<&str>, selected: Option<&str>) -> CommandResult {
381 let report = || speccoder_core::ActionsReport {
382 actions: Vec::new(),
383 journey: journey(path, feature),
384 };
385 if crate::format::at(path).detect(path).is_none() {
386 let status = crate::status::report(path, feature);
387 return CommandResult::carried("actions", &status)
388 .with_data(CommandData::Actions(Box::new(report())));
389 }
390 let mut r = report();
391 r.actions = available(path, feature, selected);
392 let message = match speccoder_core::actions::primary(&r.actions) {
393 Some(a) => format!("{} action(s); next: {}", r.actions.len(), a.label),
394 None => format!("{} action(s)", r.actions.len()),
395 };
396 CommandResult::ok("actions", &message).with_data(CommandData::Actions(Box::new(r)))
397}
398
399#[must_use]
402pub fn journey(path: &Path, feature: Option<&str>) -> Vec<speccoder_core::actions::JourneyStep> {
403 match crate::status::report(path, feature).data() {
404 Some(CommandData::Report(r)) => r.journey.clone(),
405 _ => speccoder_core::actions::journey(speccoder_core::actions::JourneyFacts::default()),
406 }
407}
408
409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
411pub enum Interface {
412 WebJobs,
414 CliFollow,
416 CliRemedy,
418 AcpRemedy,
420 Dashboard,
422}
423
424impl Interface {
425 pub const ALL: &'static [Self] = &[
427 Self::WebJobs,
428 Self::CliFollow,
429 Self::CliRemedy,
430 Self::AcpRemedy,
431 Self::Dashboard,
432 ];
433
434 #[must_use]
436 pub fn refusal(self) -> CommandResult {
437 match self {
438 Self::CliFollow => CommandResult::new(
439 "next",
440 CommandStatus::UsageError,
441 "not a step speccoder takes here",
442 ),
443 Self::Dashboard => CommandResult::new(
444 "tui",
445 CommandStatus::UsageError,
446 "not something the dashboard does",
447 ),
448 Self::WebJobs => CommandResult::new(
449 "job",
450 CommandStatus::UsageError,
451 "not something the web app runs",
452 ),
453 Self::CliRemedy | Self::AcpRemedy => CommandResult::new(
454 "fix",
455 CommandStatus::UsageError,
456 "not a fix speccoder applies here",
457 ),
458 }
459 }
460}
461
462#[derive(Debug, Clone, Copy, PartialEq, Eq)]
464pub enum Offer {
465 Take,
467 Refuse,
469}
470
471#[must_use]
474pub const fn offered(interface: Interface, kind: ActionKind) -> Offer {
475 use ActionKind as K;
476 match interface {
477 Interface::WebJobs => match kind {
478 K::Skill
479 | K::RunTask
480 | K::RunFeature
481 | K::Converge
482 | K::Commit
483 | K::Approve
484 | K::Reject
485 | K::Accept
486 | K::Skip
487 | K::Nudge
488 | K::Rule
489 | K::Review
490 | K::IssueComment
491 | K::QuickChange
492 | K::KeepSetAside
493 | K::DropSetAside
494 | K::FixSetup
495 | K::Feature
496 | K::PullRequest
497 | K::BugFix
498 | K::BugConfirm
499 | K::BugRun
500 | K::BugKeep
501 | K::AllowAlways
502 | K::WidenPolicy
503 | K::DecideBatch
504 | K::OracleTests
505 | K::ApproveAndRun
506 | K::Comment
507 | K::CommentsSend
508 | K::RevisionUndo
509 | K::CodeNudge
510 | K::AcceptanceAt
511 | K::PresetAdd
512 | K::PlanThrough
513 | K::NewProject
514 | K::UndoNewProject
515 | K::CloneRepository
516 | K::OpenSample
517 | K::SampleChecks => Offer::Take,
518 K::Command => Offer::Refuse,
519 },
520 Interface::CliFollow => match kind {
523 K::Skill | K::RunTask | K::RunFeature | K::Converge | K::Commit | K::PlanThrough => {
525 Offer::Take
526 }
527 K::Approve
528 | K::Reject
529 | K::Accept
530 | K::Skip
531 | K::Nudge
532 | K::Rule
533 | K::Review
534 | K::IssueComment
535 | K::QuickChange
536 | K::KeepSetAside
537 | K::DropSetAside
538 | K::FixSetup
539 | K::Feature
540 | K::PullRequest
541 | K::BugFix
542 | K::BugConfirm
543 | K::BugRun
544 | K::BugKeep
545 | K::AllowAlways
546 | K::WidenPolicy
547 | K::DecideBatch
548 | K::OracleTests
549 | K::ApproveAndRun
550 | K::Comment
551 | K::CommentsSend
552 | K::RevisionUndo
553 | K::CodeNudge
554 | K::AcceptanceAt
555 | K::PresetAdd
556 | K::NewProject
557 | K::UndoNewProject
558 | K::CloneRepository
559 | K::OpenSample
560 | K::SampleChecks
561 | K::Command => Offer::Refuse,
562 },
563 Interface::Dashboard => match kind {
566 K::Approve | K::Reject | K::Feature | K::AllowAlways | K::DecideBatch => Offer::Take,
567 K::Skill
568 | K::RunTask
569 | K::RunFeature
570 | K::Converge
571 | K::Commit
572 | K::Accept
573 | K::Skip
574 | K::Nudge
575 | K::Rule
576 | K::Review
577 | K::IssueComment
578 | K::QuickChange
579 | K::KeepSetAside
580 | K::DropSetAside
581 | K::FixSetup
582 | K::PullRequest
583 | K::BugFix
584 | K::BugConfirm
585 | K::BugRun
586 | K::BugKeep
587 | K::WidenPolicy
588 | K::OracleTests
589 | K::ApproveAndRun
590 | K::Comment
591 | K::CommentsSend
592 | K::RevisionUndo
593 | K::CodeNudge
594 | K::AcceptanceAt
595 | K::PresetAdd
596 | K::PlanThrough
597 | K::NewProject
598 | K::UndoNewProject
599 | K::CloneRepository
600 | K::OpenSample
601 | K::SampleChecks
602 | K::Command => Offer::Refuse,
603 },
604 Interface::CliRemedy | Interface::AcpRemedy => match kind {
605 K::FixSetup => Offer::Take,
606 K::Skill
607 | K::RunTask
608 | K::RunFeature
609 | K::Converge
610 | K::Commit
611 | K::Approve
612 | K::Reject
613 | K::Accept
614 | K::Skip
615 | K::Nudge
616 | K::Rule
617 | K::Review
618 | K::IssueComment
619 | K::QuickChange
620 | K::KeepSetAside
621 | K::DropSetAside
622 | K::Feature
623 | K::PullRequest
624 | K::BugFix
625 | K::BugConfirm
626 | K::BugRun
627 | K::BugKeep
628 | K::AllowAlways
629 | K::WidenPolicy
630 | K::DecideBatch
631 | K::OracleTests
632 | K::ApproveAndRun
633 | K::Comment
634 | K::CommentsSend
635 | K::RevisionUndo
636 | K::CodeNudge
637 | K::AcceptanceAt
638 | K::PresetAdd
639 | K::PlanThrough
640 | K::NewProject
641 | K::UndoNewProject
642 | K::CloneRepository
643 | K::OpenSample
644 | K::SampleChecks
645 | K::Command => Offer::Refuse,
646 },
647 }
648}
649
650#[derive(Debug)]
654pub struct Dispatch<'a> {
655 pub run: &'a Runnable,
657 pub input: Option<&'a str>,
660 pub via: Via,
662 pub options: RunOptions,
664}
665
666impl<'a> Dispatch<'a> {
667 #[must_use]
669 pub fn new(run: &'a Runnable, via: Via) -> Self {
670 Self {
671 run,
672 input: None,
673 via,
674 options: RunOptions::default(),
675 }
676 }
677}
678
679#[must_use]
682pub fn perform_for(
683 interface: Interface,
684 root: &Path,
685 feature: Option<&str>,
686 d: Dispatch<'_>,
687) -> Option<CommandResult> {
688 (offered(interface, d.run.kind()) == Offer::Take).then(|| perform(root, feature, d))
689}
690
691#[must_use]
696pub fn perform(root: &Path, feature: Option<&str>, d: Dispatch<'_>) -> CommandResult {
697 perform_in(root, feature, d, crate::worktree::state_dir().as_deref())
698}
699
700#[must_use]
702pub fn perform_in(
703 root: &Path,
704 feature: Option<&str>,
705 d: Dispatch<'_>,
706 state: Option<&Path>,
707) -> CommandResult {
708 use crate::approvals::{Choice, Verb};
709 let needs = |what: &str| {
710 CommandResult::new(
711 "job",
712 CommandStatus::UsageError,
713 &format!("{what} is needed"),
714 )
715 };
716 let no_state = || CommandResult::new("job", CommandStatus::ConfigError, "no state directory");
717 let decide = |verb| crate::approvals::decide_in(root, feature, verb, state, d.via);
718 let options = &d.options;
719 let via = d.via;
720 let text = d.input.map(str::trim).filter(|t| !t.is_empty());
721 match d.run {
722 Runnable::Skill { name, arguments } => skill(root, feature, name, text, arguments, options),
723 Runnable::Run { task: Some(t) } => run_task(root, feature, t, options),
724 Runnable::Run { task: None } => run_feature(root, feature, options, false),
725 Runnable::Converge => run_feature(root, feature, options, true),
726 Runnable::Commit { paths } => crate::hosting::commit_planning(root, feature, paths),
727 Runnable::Approve { task, call, digest } => decide(Verb::Decide(
728 Choice::approve(task, text).shown(call, digest),
729 )),
730 Runnable::Reject { task, call, digest } => match text {
731 Some(why) => decide(Verb::Decide(Choice::reject(task, why).shown(call, digest))),
732 None => needs("a reason"),
733 },
734 Runnable::Accept { task } => match (text, state) {
735 (Some(why), Some(s)) => crate::controls::accept_in(root, feature, task, why, s, false),
736 (None, _) => needs("a reason"),
737 (_, None) => no_state(),
738 },
739 Runnable::AcceptanceAt {
741 feature: unit,
742 story,
743 task,
744 revoke,
745 } => match state {
746 Some(s) => {
747 crate::acceptance_at::decide(root, Some(unit), (story, task, *revoke), via, s)
748 }
749 None => no_state(),
750 },
751 Runnable::PresetAdd { update } => match state {
753 Some(s) => crate::preset::add(root, *update, true, s),
754 None => no_state(),
755 },
756 Runnable::Skip { task } => match text {
757 Some(why) => crate::controls::skip(root, feature, task, Some(why)),
758 None => needs("a reason"),
759 },
760 Runnable::Nudge { task } => match (text, state) {
761 (Some(note), Some(s)) => {
762 crate::controls::nudge_with(root, feature, task, Some(note), s)
763 }
764 (None, _) => needs("a note"),
765 (_, None) => no_state(),
766 },
767 Runnable::Rule { task } => match (text, state) {
768 (Some(why), Some(s)) => crate::controls::rule(root, feature, task, why, None, s),
769 (None, _) => needs("a reason"),
770 (_, None) => no_state(),
771 },
772 Runnable::Review { ids, verdict } => match (verdict.as_str(), ids.first(), text) {
773 ("answer", Some(q), Some(t)) => crate::review::answer(root, feature, q, t),
774 ("edit" | "edit-approve", Some(id), Some(t)) => {
775 crate::review::edit(root, feature, id, t, verdict == "edit-approve", via)
776 }
777 ("answer" | "edit" | "edit-approve", _, _) => needs("the words"),
778 _ => crate::review::decide(root, feature, ids, verdict, text, via),
779 },
780 Runnable::IssueComment { issue, assign } => crate::github::comment(
781 &crate::github::Ctx::from_env(),
782 root,
783 feature,
784 &issue.to_string(),
785 *assign,
786 ),
787 Runnable::QuickChange {
788 request,
789 checks,
790 run,
791 } => quick_change(root, request, checks, *run, via, options),
792 Runnable::KeepSetAside { task } => crate::controls::restore(root, feature, task),
793 Runnable::DropSetAside { task } => crate::controls::drop_set_aside(root, feature, task),
794 Runnable::FixSetup => crate::code::fix_setup(root, feature),
795 Runnable::Feature => match text {
796 Some(name) => crate::current::switch(root, name),
797 None => needs("a feature"),
798 },
799 Runnable::BugFix {
800 symptom,
801 expected,
802 unchanged,
803 issue,
804 tests,
805 candidate,
806 slug,
807 run,
808 } => bug_fix(
809 root,
810 &crate::bug::Request::of(
811 symptom,
812 expected,
813 unchanged,
814 *issue,
815 tests,
816 candidate.as_deref(),
817 slug.as_deref(),
818 *run,
819 ),
820 via,
821 state,
822 options,
823 ),
824 Runnable::BugConfirm {
825 task,
826 digest,
827 reject,
828 } => match (*reject, text) {
829 (true, None) => needs("a reason"),
830 (true, Some(why)) => bug_confirm(root, task, digest, Some(why), via, state, options),
831 (false, _) => bug_confirm(root, task, digest, None, via, state, options),
832 },
833 Runnable::BugRun { slug, task } => {
834 bug_run(root, slug.as_deref(), task.as_deref(), state, options)
835 }
836 Runnable::BugKeep { task } => match text {
837 Some(why) => crate::bug::keep(root, task, why, state),
838 None => needs("a reason"),
839 },
840 Runnable::AllowAlways { task, call } => {
841 crate::approvals::allow_always(root, feature, task, call, state, via)
842 }
843 Runnable::WidenPolicy {
844 key,
845 prefix,
846 digest,
847 } => crate::approvals::coverage::widen(root, state, *key, prefix, digest, via),
848 Runnable::DecideBatch {
849 run,
850 verdict,
851 calls,
852 digest,
853 resume_asking,
854 } => {
855 let approve = *verdict == speccoder_core::actions::BatchVerdict::Approve;
856 if !approve && text.is_none() {
857 return needs("a reason");
858 }
859 crate::approvals::decide_batch(
860 root,
861 feature,
862 crate::approvals::BatchChoice {
863 run,
864 approve,
865 calls,
866 digest,
867 resume_asking: *resume_asking,
868 reason: text,
869 },
870 state,
871 via,
872 )
873 }
874 Runnable::OracleTests {
875 req,
876 op,
877 path,
878 scenario,
879 reading,
880 } => crate::oracle::tests(
881 root,
882 feature,
883 &crate::oracle::Request {
884 req,
885 op: *op,
886 path: path.as_deref(),
887 scenario: scenario.as_deref(),
888 reading: *reading,
889 text: d.input.filter(|t| !t.trim().is_empty()),
890 },
891 via,
892 state,
893 options,
894 ),
895 Runnable::ApproveAndRun {
896 mode,
897 grants,
898 sheet,
899 } => crate::review::approve_and_run(
900 root,
901 feature,
902 *mode,
903 grants,
904 sheet.as_deref(),
905 via,
906 state,
907 None,
908 options,
909 ),
910 Runnable::Comment { op, anchor, id } => crate::comments::comment(
911 root,
912 feature,
913 *op,
914 anchor.as_ref(),
915 id.as_deref(),
916 text,
917 via,
918 state,
919 ),
920 Runnable::CommentsSend { ids } => {
921 crate::comments::send(root, feature, ids, via, state, options)
922 }
923 Runnable::RevisionUndo { turn } => {
924 crate::comments::undo(root, feature, turn.as_deref(), state)
925 }
926 Runnable::CodeNudge { task, comments } => {
927 crate::comments::code_nudge(root, feature, task, comments, state)
928 }
929 Runnable::PlanThrough {
932 from:
933 speccoder_core::actions::PlanFrom::Create {
934 description,
935 source,
936 },
937 through,
938 } => {
939 let description = if description.trim().is_empty() {
940 text.unwrap_or_default()
941 } else {
942 description.as_str()
943 };
944 plan_through(root, description, source, *through, options)
945 }
946 Runnable::PlanThrough {
947 from: speccoder_core::actions::PlanFrom::Continue,
948 through,
949 } => crate::planning_chain::resume(root, feature, *through, options),
950 Runnable::NewProject {
952 target,
953 choices,
954 plan_digest,
955 } => match state {
956 Some(s) => crate::new_project::perform(
957 Path::new(target),
958 choices,
959 plan_digest,
960 via,
961 s,
962 options,
963 ),
964 None => no_state(),
965 },
966 Runnable::UndoNewProject { commit } => match state {
967 Some(s) => crate::new_project::undo(root, commit, via, s, options),
968 None => no_state(),
969 },
970 Runnable::CloneRepository { repo, target } => {
972 crate::github::clone(&crate::github::Ctx::from_env(), repo, Path::new(target))
973 }
974 Runnable::OpenSample { fresh } => match state {
975 Some(s) => crate::sample::open(s, *fresh),
976 None => no_state(),
977 },
978 Runnable::SampleChecks => match state {
979 Some(s) => crate::sample::run_checks(s, root),
980 None => no_state(),
981 },
982 Runnable::PullRequest => crate::handoff::pr(
985 root,
986 feature,
987 &crate::handoff::PrOptions {
988 remote: "origin",
989 base: None,
990 open: true,
991 force: false,
992 github: &crate::github::Ctx::from_env(),
993 },
994 ),
995 }
996}
997
998#[must_use]
1002pub fn allow_always_inline(
1003 root: &Path,
1004 feature: &str,
1005 always: &[crate::substrate::Always],
1006 state: &Path,
1007) -> Vec<String> {
1008 always
1009 .iter()
1010 .map(|a| {
1011 let task = a.task.rsplit('/').next().unwrap_or(&a.task).to_owned();
1012 let run = Runnable::AllowAlways {
1013 task,
1014 call: a.call.clone(),
1015 };
1016 perform_in(root, Some(feature), Dispatch::new(&run, a.via), Some(state))
1017 .message()
1018 .to_owned()
1019 })
1020 .collect()
1021}
1022
1023fn skill(
1028 root: &Path,
1029 feature: Option<&str>,
1030 name: &str,
1031 input: Option<&str>,
1032 carried: &str,
1033 options: &RunOptions,
1034) -> CommandResult {
1035 crate::hosting::run(root, feature, name, input.unwrap_or(carried), options)
1036}
1037
1038fn plan_through(
1039 root: &Path,
1040 description: &str,
1041 source: &speccoder_core::actions::FeatureSource,
1042 through: speccoder_core::actions::Through,
1043 options: &RunOptions,
1044) -> CommandResult {
1045 crate::planning_chain::start(root, description, source, through, options)
1046}
1047
1048fn run_task(root: &Path, feature: Option<&str>, task: &str, options: &RunOptions) -> CommandResult {
1049 crate::run(root, feature, task, options)
1050}
1051
1052fn run_feature(
1053 root: &Path,
1054 feature: Option<&str>,
1055 options: &RunOptions,
1056 converge: bool,
1057) -> CommandResult {
1058 crate::run_feature(root, feature, options, converge)
1059}
1060
1061fn bug_fix(
1062 root: &Path,
1063 req: &crate::bug::Request,
1064 via: Via,
1065 state: Option<&Path>,
1066 options: &RunOptions,
1067) -> CommandResult {
1068 crate::bug::bug(root, req, via, state, options)
1069}
1070
1071fn bug_confirm(
1072 root: &Path,
1073 task: &str,
1074 digest: &str,
1075 reject: Option<&str>,
1076 via: Via,
1077 state: Option<&Path>,
1078 options: &RunOptions,
1079) -> CommandResult {
1080 crate::bug::confirm(root, task, digest, reject, true, via, state, options)
1081}
1082
1083fn bug_run(
1084 root: &Path,
1085 slug: Option<&str>,
1086 task: Option<&str>,
1087 state: Option<&Path>,
1088 options: &RunOptions,
1089) -> CommandResult {
1090 crate::bug::run(root, slug, task, state, options)
1091}
1092
1093fn quick_change(
1094 root: &Path,
1095 request: &str,
1096 checks: &[String],
1097 run: bool,
1098 via: Via,
1099 options: &RunOptions,
1100) -> CommandResult {
1101 crate::quick::change(root, request, checks, run, via, options)
1102}
1103
1104#[cfg(test)]
1105mod tests {
1106 use speccoder_core::actions::primary;
1107
1108 use super::*;
1109
1110 #[test]
1111 fn every_interface_decides_every_kind() {
1112 let mut taken = 0;
1113 for i in Interface::ALL {
1114 for k in ActionKind::ALL {
1115 if offered(*i, *k) == Offer::Take {
1116 taken += 1;
1117 } else {
1118 assert_ne!(i.refusal().status(), CommandStatus::Ok, "{i:?}");
1119 }
1120 }
1121 }
1122 let runnable = Runnable::examples().len();
1126 assert_eq!(taken, runnable + 6 + 5 + 2);
1128 assert_eq!(
1129 offered(Interface::CliFollow, ActionKind::PullRequest),
1130 Offer::Refuse
1131 );
1132 assert_eq!(
1133 offered(Interface::AcpRemedy, ActionKind::FixSetup),
1134 Offer::Take
1135 );
1136 }
1137
1138 #[test]
1139 fn blank_input_is_no_input() {
1140 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1141 let run = Runnable::Reject {
1142 task: "T001".to_owned(),
1143 call: "attempt:T001".to_owned(),
1144 digest: String::new(),
1145 };
1146 let r = perform_in(
1147 dir.path(),
1148 None,
1149 Dispatch {
1150 run: &run,
1151 input: Some(" "),
1152 via: Via::Web,
1153 options: RunOptions::default(),
1154 },
1155 None,
1156 );
1157 assert_eq!(
1158 (r.command(), r.status(), r.message()),
1159 ("job", CommandStatus::UsageError, "a reason is needed")
1160 );
1161 }
1162
1163 #[test]
1164 fn a_project_outside_git_still_gets_a_list() {
1165 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1166 let list = available(dir.path(), None, None);
1167 assert!(list.iter().any(|a| a.command == "speccoder doctor"));
1168 assert!(primary(&list).is_none());
1169 }
1170}